diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /browser/components/sessionstore | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
289 files changed, 35831 insertions, 0 deletions
diff --git a/browser/components/sessionstore/ContentRestore.sys.mjs b/browser/components/sessionstore/ContentRestore.sys.mjs new file mode 100644 index 0000000000..7cfd5c02ff --- /dev/null +++ b/browser/components/sessionstore/ContentRestore.sys.mjs @@ -0,0 +1,430 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", + Utils: "resource://gre/modules/sessionstore/Utils.sys.mjs", +}); + +/** + * This module implements the content side of session restoration. The chrome + * side is handled by SessionStore.sys.mjs. The functions in this module are called + * by content-sessionStore.js based on messages received from SessionStore.sys.mjs + * (or, in one case, based on a "load" event). Each tab has its own + * ContentRestore instance, constructed by content-sessionStore.js. + * + * In a typical restore, content-sessionStore.js will call the following based + * on messages and events it receives: + * + * restoreHistory(tabData, loadArguments, callbacks) + * Restores the tab's history and session cookies. + * restoreTabContent(loadArguments, finishCallback) + * Starts loading the data for the current page to restore. + * restoreDocument() + * Restore form and scroll data. + * + * When the page has been loaded from the network, we call finishCallback. It + * should send a message to SessionStore.sys.mjs, which may cause other tabs to be + * restored. + * + * When the page has finished loading, a "load" event will trigger in + * content-sessionStore.js, which will call restoreDocument. At that point, + * form data is restored and the restore is complete. + * + * At any time, SessionStore.sys.mjs can cancel the ongoing restore by sending a + * reset message, which causes resetRestore to be called. At that point it's + * legal to begin another restore. + */ +export function ContentRestore(chromeGlobal) { + let internal = new ContentRestoreInternal(chromeGlobal); + let external = {}; + + let EXPORTED_METHODS = [ + "restoreHistory", + "restoreTabContent", + "restoreDocument", + "resetRestore", + ]; + + for (let method of EXPORTED_METHODS) { + external[method] = internal[method].bind(internal); + } + + return Object.freeze(external); +} + +function ContentRestoreInternal(chromeGlobal) { + this.chromeGlobal = chromeGlobal; + + // The following fields are only valid during certain phases of the restore + // process. + + // The tabData for the restore. Set in restoreHistory and removed in + // restoreTabContent. + this._tabData = null; + + // Contains {entry, scrollPositions, formdata}, where entry is a + // single entry from the tabData.entries array. Set in + // restoreTabContent and removed in restoreDocument. + this._restoringDocument = null; + + // This listener is used to detect reloads on restoring tabs. Set in + // restoreHistory and removed in restoreTabContent. + this._historyListener = null; + + // This listener detects when a pending tab starts loading (when not + // initiated by sessionstore) and when a restoring tab has finished loading + // data from the network. Set in restoreHistory() and restoreTabContent(), + // removed in resetRestore(). + this._progressListener = null; +} + +/** + * The API for the ContentRestore module. Methods listed in EXPORTED_METHODS are + * public. + */ +ContentRestoreInternal.prototype = { + get docShell() { + return this.chromeGlobal.docShell; + }, + + /** + * Starts the process of restoring a tab. The tabData to be restored is passed + * in here and used throughout the restoration. The epoch (which must be + * non-zero) is passed through to all the callbacks. If a load in the tab + * is started while it is pending, the appropriate callbacks are called. + */ + restoreHistory(tabData, loadArguments, callbacks) { + this._tabData = tabData; + + // In case about:blank isn't done yet. + let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); + webNavigation.stop(Ci.nsIWebNavigation.STOP_ALL); + + // Make sure currentURI is set so that switch-to-tab works before the tab is + // restored. We'll reset this to about:blank when we try to restore the tab + // to ensure that docshell doeesn't get confused. Don't bother doing this if + // we're restoring immediately due to a process switch. It just causes the + // URL bar to be temporarily blank. + let activeIndex = tabData.index - 1; + let activePageData = tabData.entries[activeIndex] || {}; + let uri = activePageData.url || null; + if (uri && !loadArguments) { + webNavigation.setCurrentURIForSessionStore(Services.io.newURI(uri)); + } + + lazy.SessionHistory.restore(this.docShell, tabData); + + // Add a listener to watch for reloads. + let listener = new HistoryListener(this.docShell, () => { + // On reload, restore tab contents. + this.restoreTabContent(null, false, callbacks.onLoadFinished); + }); + + webNavigation.sessionHistory.legacySHistory.addSHistoryListener(listener); + this._historyListener = listener; + + // Make sure to reset the capabilities and attributes in case this tab gets + // reused. + SessionStoreUtils.restoreDocShellCapabilities( + this.docShell, + tabData.disallow + ); + + // Add a progress listener to correctly handle browser.loadURI() + // calls from foreign code. + this._progressListener = new ProgressListener(this.docShell, { + onStartRequest: () => { + // Some code called browser.loadURI() on a pending tab. It's safe to + // assume we don't care about restoring scroll or form data. + this._tabData = null; + + // Listen for the tab to finish loading. + this.restoreTabContentStarted(callbacks.onLoadFinished); + + // Notify the parent. + callbacks.onLoadStarted(); + }, + }); + }, + + /** + * Start loading the current page. When the data has finished loading from the + * network, finishCallback is called. Returns true if the load was successful. + */ + restoreTabContent(loadArguments, isRemotenessUpdate, finishCallback) { + let tabData = this._tabData; + this._tabData = null; + + let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); + + // Listen for the tab to finish loading. + this.restoreTabContentStarted(finishCallback); + + // Reset the current URI to about:blank. We changed it above for + // switch-to-tab, but now it must go back to the correct value before the + // load happens. Don't bother doing this if we're restoring immediately + // due to a process switch. + if (!isRemotenessUpdate) { + webNavigation.setCurrentURIForSessionStore( + Services.io.newURI("about:blank") + ); + } + + try { + if (loadArguments) { + // If the load was started in another process, and the in-flight channel + // was redirected into this process, resume that load within our process. + // + // NOTE: In this case `isRemotenessUpdate` must be true. + webNavigation.resumeRedirectedLoad( + loadArguments.redirectLoadSwitchId, + loadArguments.redirectHistoryIndex + ); + } else if (tabData.userTypedValue && tabData.userTypedClear) { + // If the user typed a URL into the URL bar and hit enter right before + // we crashed, we want to start loading that page again. A non-zero + // userTypedClear value means that the load had started. + // Load userTypedValue and fix up the URL if it's partial/broken. + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP, + }; + webNavigation.loadURI(tabData.userTypedValue, loadURIOptions); + } else if (tabData.entries.length) { + // Stash away the data we need for restoreDocument. + this._restoringDocument = { + formdata: tabData.formdata || {}, + scrollPositions: tabData.scroll || {}, + }; + + // In order to work around certain issues in session history, we need to + // force session history to update its internal index and call reload + // instead of gotoIndex. See bug 597315. + let history = webNavigation.sessionHistory.legacySHistory; + history.reloadCurrentEntry(); + } else { + // If there's nothing to restore, we should still blank the page. + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY, + // Specify an override to force the load to finish in the current + // process, as tests rely on this behaviour for non-fission session + // restore. + remoteTypeOverride: Services.appinfo.remoteType, + }; + webNavigation.loadURI("about:blank", loadURIOptions); + } + + return true; + } catch (ex) { + if (ex instanceof Ci.nsIException) { + // Ignore page load errors, but return false to signal that the load never + // happened. + return false; + } + } + return null; + }, + + /** + * To be called after restoreHistory(). Removes all listeners needed for + * pending tabs and makes sure to notify when the tab finished loading. + */ + restoreTabContentStarted(finishCallback) { + // The reload listener is no longer needed. + this._historyListener.uninstall(); + this._historyListener = null; + + // Remove the old progress listener. + this._progressListener.uninstall(); + + // We're about to start a load. This listener will be called when the load + // has finished getting everything from the network. + this._progressListener = new ProgressListener(this.docShell, { + onStopRequest: () => { + // Call resetRestore() to reset the state back to normal. The data + // needed for restoreDocument() (which hasn't happened yet) will + // remain in _restoringDocument. + this.resetRestore(); + + finishCallback(); + }, + }); + }, + + /** + * Finish restoring the tab by filling in form data and setting the scroll + * position. The restore is complete when this function exits. It should be + * called when the "load" event fires for the restoring tab. Returns true + * if we're restoring a document. + */ + restoreDocument() { + if (!this._restoringDocument) { + return; + } + + let { formdata, scrollPositions } = this._restoringDocument; + this._restoringDocument = null; + + let window = this.docShell.domWindow; + + // Restore form data. + lazy.Utils.restoreFrameTreeData(window, formdata, (frame, data) => { + // restore() will return false, and thus abort restoration for the + // current |frame| and its descendants, if |data.url| is given but + // doesn't match the loaded document's URL. + return SessionStoreUtils.restoreFormData(frame.document, data); + }); + + // Restore scroll data. + lazy.Utils.restoreFrameTreeData(window, scrollPositions, (frame, data) => { + if (data.scroll) { + SessionStoreUtils.restoreScrollPosition(frame, data); + } + }); + }, + + /** + * Cancel an ongoing restore. This function can be called any time between + * restoreHistory and restoreDocument. + * + * This function is called externally (if a restore is canceled) and + * internally (when the loads for a restore have finished). In the latter + * case, it's called before restoreDocument, so it cannot clear + * _restoringDocument. + */ + resetRestore() { + this._tabData = null; + + if (this._historyListener) { + this._historyListener.uninstall(); + } + this._historyListener = null; + + if (this._progressListener) { + this._progressListener.uninstall(); + } + this._progressListener = null; + }, +}; + +/* + * This listener detects when a page being restored is reloaded. It triggers a + * callback and cancels the reload. The callback will send a message to + * SessionStore.sys.mjs so that it can restore the content immediately. + */ +function HistoryListener(docShell, callback) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + webNavigation.sessionHistory.legacySHistory.addSHistoryListener(this); + + this.webNavigation = webNavigation; + this.callback = callback; +} +HistoryListener.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]), + + uninstall() { + let shistory = this.webNavigation.sessionHistory.legacySHistory; + if (shistory) { + shistory.removeSHistoryListener(this); + } + }, + + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReplaceEntry() {}, + + // This will be called for a pending tab when loadURI(uri) is called where + // the given |uri| only differs in the fragment. + OnHistoryNewEntry(newURI) { + let currentURI = this.webNavigation.currentURI; + + // Ignore new SHistory entries with the same URI as those do not indicate + // a navigation inside a document by changing the #hash part of the URL. + // We usually hit this when purging session history for browsers. + if (currentURI && currentURI.spec == newURI.spec) { + return; + } + + // Reset the tab's URL to what it's actually showing. Without this loadURI() + // would use the current document and change the displayed URL only. + this.webNavigation.setCurrentURIForSessionStore( + Services.io.newURI("about:blank") + ); + + // Kick off a new load so that we navigate away from about:blank to the + // new URL that was passed to loadURI(). The new load will cause a + // STATE_START notification to be sent and the ProgressListener will then + // notify the parent and do the rest. + let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP; + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags, + }; + this.webNavigation.loadURI(newURI.spec, loadURIOptions); + }, + + OnHistoryReload() { + this.callback(); + + // Cancel the load. + return false; + }, +}; + +/** + * This class informs SessionStore.sys.mjs whenever the network requests for a + * restoring page have completely finished. We only restore three tabs + * simultaneously, so this is the signal for SessionStore.sys.mjs to kick off + * another restore (if there are more to do). + * + * The progress listener is also used to be notified when a load not initiated + * by sessionstore starts. Pending tabs will then need to be marked as no + * longer pending. + */ +function ProgressListener(docShell, callbacks) { + let webProgress = docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW); + + this.webProgress = webProgress; + this.callbacks = callbacks; +} + +ProgressListener.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + uninstall() { + this.webProgress.removeProgressListener(this); + }, + + onStateChange(webProgress, request, stateFlags, status) { + let { + STATE_IS_WINDOW, + STATE_STOP, + STATE_START, + } = Ci.nsIWebProgressListener; + if (!webProgress.isTopLevel || !(stateFlags & STATE_IS_WINDOW)) { + return; + } + + if (stateFlags & STATE_START && this.callbacks.onStartRequest) { + this.callbacks.onStartRequest(); + } + + if (stateFlags & STATE_STOP && this.callbacks.onStopRequest) { + this.callbacks.onStopRequest(); + } + }, +}; diff --git a/browser/components/sessionstore/ContentSessionStore.sys.mjs b/browser/components/sessionstore/ContentSessionStore.sys.mjs new file mode 100644 index 0000000000..e4d0e6e711 --- /dev/null +++ b/browser/components/sessionstore/ContentSessionStore.sys.mjs @@ -0,0 +1,690 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { + clearTimeout, + setTimeoutWithTarget, +} from "resource://gre/modules/Timer.sys.mjs"; + +function debug(msg) { + Services.console.logStringMessage("SessionStoreContent: " + msg); +} + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContentRestore: "resource:///modules/sessionstore/ContentRestore.sys.mjs", + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", +}); + +// This pref controls whether or not we send updates to the parent on a timeout +// or not, and should only be used for tests or debugging. +const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates"; + +const PREF_INTERVAL = "browser.sessionstore.interval"; + +const kNoIndex = Number.MAX_SAFE_INTEGER; +const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + +class Handler { + constructor(store) { + this.store = store; + } + + get contentRestore() { + return this.store.contentRestore; + } + + get contentRestoreInitialized() { + return this.store.contentRestoreInitialized; + } + + get mm() { + return this.store.mm; + } + + get messageQueue() { + return this.store.messageQueue; + } +} + +/** + * Listens for and handles content events that we need for the + * session store service to be notified of state changes in content. + */ +class EventListener extends Handler { + constructor(store) { + super(store); + + SessionStoreUtils.addDynamicFrameFilteredListener( + this.mm, + "load", + this, + true + ); + } + + handleEvent(event) { + let { content } = this.mm; + + // Ignore load events from subframes. + if (event.target != content.document) { + return; + } + + if (content.document.documentURI.startsWith("about:reader")) { + if ( + event.type == "load" && + !content.document.body.classList.contains("loaded") + ) { + // Don't restore the scroll position of an about:reader page at this + // point; listen for the custom event dispatched from AboutReader.jsm. + content.addEventListener("AboutReaderContentReady", this); + return; + } + + content.removeEventListener("AboutReaderContentReady", this); + } + + if (this.contentRestoreInitialized) { + // Restore the form data and scroll position. + this.contentRestore.restoreDocument(); + } + } +} + +/** + * Listens for changes to the session history. Whenever the user navigates + * we will collect URLs and everything belonging to session history. + * + * Causes a SessionStore:update message to be sent that contains the current + * session history. + * + * Example: + * {entries: [{url: "about:mozilla", ...}, ...], index: 1} + */ +class SessionHistoryListener extends Handler { + constructor(store) { + super(store); + + this._fromIdx = kNoIndex; + + // By adding the SHistoryListener immediately, we will unfortunately be + // notified of every history entry as the tab is restored. We don't bother + // waiting to add the listener later because these notifications are cheap. + // We will likely only collect once since we are batching collection on + // a delay. + this.mm.docShell + .QueryInterface(Ci.nsIWebNavigation) + .sessionHistory.legacySHistory.addSHistoryListener(this); // OK in non-geckoview + + let webProgress = this.mm.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + + webProgress.addProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT + ); + + // Collect data if we start with a non-empty shistory. + if (!lazy.SessionHistory.isEmpty(this.mm.docShell)) { + this.collect(); + // When a tab is detached from the window, for the new window there is a + // new SessionHistoryListener created. Normally it is empty at this point + // but in a test env. the initial about:blank might have a children in which + // case we fire off a history message here with about:blank in it. If we + // don't do it ASAP then there is going to be a browser swap and the parent + // will be all confused by that message. + this.store.messageQueue.send(); + } + + // Listen for page title changes. + this.mm.addEventListener("DOMTitleChanged", this); + } + + get mm() { + return this.store.mm; + } + + uninit() { + let sessionHistory = this.mm.docShell.QueryInterface(Ci.nsIWebNavigation) + .sessionHistory; + if (sessionHistory) { + sessionHistory.legacySHistory.removeSHistoryListener(this); // OK in non-geckoview + } + } + + collect() { + // We want to send down a historychange even for full collects in case our + // session history is a partial session history, in which case we don't have + // enough information for a full update. collectFrom(-1) tells the collect + // function to collect all data avaliable in this process. + if (this.mm.docShell) { + this.collectFrom(-1); + } + } + + // History can grow relatively big with the nested elements, so if we don't have to, we + // don't want to send the entire history all the time. For a simple optimization + // we keep track of the smallest index from after any change has occured and we just send + // the elements from that index. If something more complicated happens we just clear it + // and send the entire history. We always send the additional info like the current selected + // index (so for going back and forth between history entries we set the index to kLastIndex + // if nothing else changed send an empty array and the additonal info like the selected index) + collectFrom(idx) { + if (this._fromIdx <= idx) { + // If we already know that we need to update history fromn index N we can ignore any changes + // tha happened with an element with index larger than N. + // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which means we don't ignore anything + // here, and in case of navigation in the history back and forth we use kLastIndex which ignores + // only the subsequent navigations, but not any new elements added. + return; + } + + this._fromIdx = idx; + this.store.messageQueue.push("historychange", () => { + if (this._fromIdx === kNoIndex) { + return null; + } + + let history = lazy.SessionHistory.collect( + this.mm.docShell, + this._fromIdx + ); + this._fromIdx = kNoIndex; + return history; + }); + } + + handleEvent(event) { + this.collect(); + } + + OnHistoryNewEntry(newURI, oldIndex) { + // Collect the current entry as well, to make sure to collect any changes + // that were made to the entry while the document was active. + this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1); + } + + OnHistoryGotoIndex() { + // We ought to collect the previously current entry as well, see bug 1350567. + this.collectFrom(kLastIndex); + } + + OnHistoryPurge() { + this.collect(); + } + + OnHistoryReload() { + this.collect(); + return true; + } + + OnHistoryReplaceEntry() { + this.collect(); + } + + /** + * @see nsIWebProgressListener.onStateChange + */ + onStateChange(webProgress, request, stateFlags, status) { + // Ignore state changes for subframes because we're only interested in the + // top-document starting or stopping its load. + if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.content) { + return; + } + + // onStateChange will be fired when loading the initial about:blank URI for + // a browser, which we don't actually care about. This is particularly for + // the case of unrestored background tabs, where the content has not yet + // been restored: we don't want to accidentally send any updates to the + // parent when the about:blank placeholder page has loaded. + if (!this.mm.docShell.hasLoadedNonBlankURI) { + return; + } + + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + this.collect(); + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + this.collect(); + } + } +} +SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISHistoryListener", + "nsISupportsWeakReference", +]); + +/** + * A message queue that takes collected data and will take care of sending it + * to the chrome process. It allows flushing using synchronous messages and + * takes care of any race conditions that might occur because of that. Changes + * will be batched if they're pushed in quick succession to avoid a message + * flood. + */ +class MessageQueue extends Handler { + constructor(store) { + super(store); + + /** + * A map (string -> lazy fn) holding lazy closures of all queued data + * collection routines. These functions will return data collected from the + * docShell. + */ + this._data = new Map(); + + /** + * The delay (in ms) used to delay sending changes after data has been + * invalidated. + */ + this.BATCH_DELAY_MS = 1000; + + /** + * The minimum idle period (in ms) we need for sending data to chrome process. + */ + this.NEEDED_IDLE_PERIOD_MS = 5; + + /** + * Timeout for waiting an idle period to send data. We will set this from + * the pref "browser.sessionstore.interval". + */ + this._timeoutWaitIdlePeriodMs = null; + + /** + * The current timeout ID, null if there is no queue data. We use timeouts + * to damp a flood of data changes and send lots of changes as one batch. + */ + this._timeout = null; + + /** + * Whether or not sending batched messages on a timer is disabled. This should + * only be used for debugging or testing. If you need to access this value, + * you should probably use the timeoutDisabled getter. + */ + this._timeoutDisabled = false; + + /** + * True if there is already a send pending idle dispatch, set to prevent + * scheduling more than one. If false there may or may not be one scheduled. + */ + this._idleScheduled = false; + + this.timeoutDisabled = Services.prefs.getBoolPref(TIMEOUT_DISABLED_PREF); + this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(PREF_INTERVAL); + + Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this); + Services.prefs.addObserver(PREF_INTERVAL, this); + } + + /** + * True if batched messages are not being fired on a timer. This should only + * ever be true when debugging or during tests. + */ + get timeoutDisabled() { + return this._timeoutDisabled; + } + + /** + * Disables sending batched messages on a timer. Also cancels any pending + * timers. + */ + set timeoutDisabled(val) { + this._timeoutDisabled = val; + + if (val && this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + } + + uninit() { + Services.prefs.removeObserver(TIMEOUT_DISABLED_PREF, this); + Services.prefs.removeObserver(PREF_INTERVAL, this); + this.cleanupTimers(); + } + + /** + * Cleanup pending idle callback and timer. + */ + cleanupTimers() { + this._idleScheduled = false; + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + } + + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + switch (data) { + case TIMEOUT_DISABLED_PREF: + this.timeoutDisabled = Services.prefs.getBoolPref( + TIMEOUT_DISABLED_PREF + ); + break; + case PREF_INTERVAL: + this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref( + PREF_INTERVAL + ); + break; + default: + debug("received unknown message '" + data + "'"); + break; + } + } + } + + /** + * Pushes a given |value| onto the queue. The given |key| represents the type + * of data that is stored and can override data that has been queued before + * but has not been sent to the parent process, yet. + * + * @param key (string) + * A unique identifier specific to the type of data this is passed. + * @param fn (function) + * A function that returns the value that will be sent to the parent + * process. + */ + push(key, fn) { + this._data.set(key, fn); + + if (!this._timeout && !this._timeoutDisabled) { + // Wait a little before sending the message to batch multiple changes. + this._timeout = setTimeoutWithTarget( + () => this.sendWhenIdle(), + this.BATCH_DELAY_MS, + this.mm.tabEventTarget + ); + } + } + + /** + * Sends queued data when the remaining idle time is enough or waiting too + * long; otherwise, request an idle time again. If the |deadline| is not + * given, this function is going to schedule the first request. + * + * @param deadline (object) + * An IdleDeadline object passed by idleDispatch(). + */ + sendWhenIdle(deadline) { + if (!this.mm.content) { + // The frameloader is being torn down. Nothing more to do. + return; + } + + if (deadline) { + if ( + deadline.didTimeout || + deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS + ) { + this.send(); + return; + } + } else if (this._idleScheduled) { + // Bail out if there's a pending run. + return; + } + ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), { + timeout: this._timeoutWaitIdlePeriodMs, + }); + this._idleScheduled = true; + } + + /** + * Sends queued data to the chrome process. + * + * @param options (object) + * {flushID: 123} to specify that this is a flush + * {isFinal: true} to signal this is the final message sent on unload + */ + send(options = {}) { + // Looks like we have been called off a timeout after the tab has been + // closed. The docShell is gone now and we can just return here as there + // is nothing to do. + if (!this.mm.docShell) { + return; + } + + this.cleanupTimers(); + + let flushID = (options && options.flushID) || 0; + let histID = "FX_SESSION_RESTORE_CONTENT_COLLECT_DATA_MS"; + + let data = {}; + for (let [key, func] of this._data) { + if (key != "isPrivate") { + TelemetryStopwatch.startKeyed(histID, key); + } + + let value = func(); + + if (key != "isPrivate") { + TelemetryStopwatch.finishKeyed(histID, key); + } + + if (value || (key != "storagechange" && key != "historychange")) { + data[key] = value; + } + } + + this._data.clear(); + + try { + // Send all data to the parent process. + this.mm.sendAsyncMessage("SessionStore:update", { + data, + flushID, + isFinal: options.isFinal || false, + epoch: this.store.epoch, + }); + } catch (ex) { + if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) { + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_SEND_UPDATE_CAUSED_OOM") + .add(1); + this.mm.sendAsyncMessage("SessionStore:error"); + } + } + } +} + +/** + * Listens for and handles messages sent by the session store service. + */ +const MESSAGES = [ + "SessionStore:restoreHistory", + "SessionStore:restoreTabContent", + "SessionStore:resetRestore", + "SessionStore:flush", + "SessionStore:prepareForProcessChange", +]; + +export class ContentSessionStore { + constructor(mm) { + if (Services.appinfo.sessionHistoryInParent) { + throw new Error("This frame script should not be loaded for SHIP"); + } + + this.mm = mm; + this.messageQueue = new MessageQueue(this); + + this.epoch = 0; + + this.contentRestoreInitialized = false; + + this.handlers = [ + this.messageQueue, + new EventListener(this), + new SessionHistoryListener(this), + ]; + + XPCOMUtils.defineLazyGetter(this, "contentRestore", () => { + this.contentRestoreInitialized = true; + return new lazy.ContentRestore(mm); + }); + + MESSAGES.forEach(m => mm.addMessageListener(m, this)); + + mm.addEventListener("unload", this); + } + + receiveMessage({ name, data }) { + // The docShell might be gone. Don't process messages, + // that will just lead to errors anyway. + if (!this.mm.docShell) { + return; + } + + // A fresh tab always starts with epoch=0. The parent has the ability to + // override that to signal a new era in this tab's life. This enables it + // to ignore async messages that were already sent but not yet received + // and would otherwise confuse the internal tab state. + if (data && data.epoch && data.epoch != this.epoch) { + this.epoch = data.epoch; + } + + switch (name) { + case "SessionStore:restoreHistory": + this.restoreHistory(data); + break; + case "SessionStore:restoreTabContent": + this.restoreTabContent(data); + break; + case "SessionStore:resetRestore": + this.contentRestore.resetRestore(); + break; + case "SessionStore:flush": + this.flush(data); + break; + case "SessionStore:prepareForProcessChange": + // During normal in-process navigations, the DocShell would take + // care of automatically persisting layout history state to record + // scroll positions on the nsSHEntry. Unfortunately, process switching + // is not a normal navigation, so for now we do this ourselves. This + // is a workaround until session history state finally lives in the + // parent process. + this.mm.docShell.persistLayoutHistoryState(); + break; + default: + debug("received unknown message '" + name + "'"); + break; + } + } + + // non-SHIP only + restoreHistory(data) { + let { epoch, tabData, loadArguments, isRemotenessUpdate } = data; + + this.contentRestore.restoreHistory(tabData, loadArguments, { + // Note: The callbacks passed here will only be used when a load starts + // that was not initiated by sessionstore itself. This can happen when + // some code calls browser.loadURI() or browser.reload() on a pending + // browser/tab. + + onLoadStarted: () => { + // Notify the parent that the tab is no longer pending. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", { + epoch, + }); + }, + + onLoadFinished: () => { + // Tell SessionStore.sys.mjs that it may want to restore some more tabs, + // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { + epoch, + }); + }, + }); + + if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT) { + // For non-remote tabs, when restoreHistory finishes, we send a synchronous + // message to SessionStore.sys.mjs so that it can run SSTabRestoring. Users of + // SSTabRestoring seem to get confused if chrome and content are out of + // sync about the state of the restore (particularly regarding + // docShell.currentURI). Using a synchronous message is the easiest way + // to temporarily synchronize them. + // + // For remote tabs, because all nsIWebProgress notifications are sent + // asynchronously using messages, we get the same-order guarantees of the + // message manager, and can use an async message. + this.mm.sendSyncMessage("SessionStore:restoreHistoryComplete", { + epoch, + isRemotenessUpdate, + }); + } else { + this.mm.sendAsyncMessage("SessionStore:restoreHistoryComplete", { + epoch, + isRemotenessUpdate, + }); + } + } + + restoreTabContent({ loadArguments, isRemotenessUpdate, reason }) { + let epoch = this.epoch; + + // We need to pass the value of didStartLoad back to SessionStore.sys.mjs. + let didStartLoad = this.contentRestore.restoreTabContent( + loadArguments, + isRemotenessUpdate, + () => { + // Tell SessionStore.sys.mjs that it may want to restore some more tabs, + // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { + epoch, + isRemotenessUpdate, + }); + } + ); + + this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", { + epoch, + isRemotenessUpdate, + reason, + }); + + if (!didStartLoad) { + // Pretend that the load succeeded so that event handlers fire correctly. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { + epoch, + isRemotenessUpdate, + }); + } + } + + flush({ id }) { + // Flush the message queue, send the latest updates. + this.messageQueue.send({ flushID: id }); + } + + handleEvent(event) { + if (event.type == "unload") { + this.onUnload(); + } + } + + onUnload() { + // Upon frameLoader destruction, send a final update message to + // the parent and flush all data currently held in the child. + this.messageQueue.send({ isFinal: true }); + + for (let handler of this.handlers) { + if (handler.uninit) { + handler.uninit(); + } + } + + if (this.contentRestoreInitialized) { + // Remove progress listeners. + this.contentRestore.resetRestore(); + } + + // We don't need to take care of any StateChangeNotifier observers as they + // will die with the content script. The same goes for the privacy transition + // observer that will die with the docShell when the tab is closed. + } +} diff --git a/browser/components/sessionstore/GlobalState.sys.mjs b/browser/components/sessionstore/GlobalState.sys.mjs new file mode 100644 index 0000000000..a49fe4650d --- /dev/null +++ b/browser/components/sessionstore/GlobalState.sys.mjs @@ -0,0 +1,88 @@ +/* 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/. */ + +const EXPORTED_METHODS = [ + "getState", + "clear", + "get", + "set", + "delete", + "setFromState", +]; + +/** + * Module that contains global session data. + */ +export function GlobalState() { + let internal = new GlobalStateInternal(); + let external = {}; + for (let method of EXPORTED_METHODS) { + external[method] = internal[method].bind(internal); + } + return Object.freeze(external); +} + +function GlobalStateInternal() { + // Storage for global state. + this.state = {}; +} + +GlobalStateInternal.prototype = { + /** + * Get all value from the global state. + */ + getState() { + return this.state; + }, + + /** + * Clear all currently stored global state. + */ + clear() { + this.state = {}; + }, + + /** + * Retrieve a value from the global state. + * + * @param aKey + * A key the value is stored under. + * @return The value stored at aKey, or an empty string if no value is set. + */ + get(aKey) { + return this.state[aKey] || ""; + }, + + /** + * Set a global value. + * + * @param aKey + * A key to store the value under. + */ + set(aKey, aStringValue) { + this.state[aKey] = aStringValue; + }, + + /** + * Delete a global value. + * + * @param aKey + * A key to delete the value for. + */ + delete(aKey) { + delete this.state[aKey]; + }, + + /** + * Set the current global state from a state object. Any previous global + * state will be removed, even if the new state does not contain a matching + * key. + * + * @param aState + * A state object to extract global state from to be set. + */ + setFromState(aState) { + this.state = (aState && aState.global) || {}; + }, +}; diff --git a/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs b/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs new file mode 100644 index 0000000000..ab05047c2b --- /dev/null +++ b/browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs @@ -0,0 +1,285 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PluralForm: "resource://gre/modules/PluralForm.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +var navigatorBundle = Services.strings.createBundle( + "chrome://browser/locale/browser.properties" +); + +export var RecentlyClosedTabsAndWindowsMenuUtils = { + /** + * Builds up a document fragment of UI items for the recently closed tabs. + * @param aWindow + * The window that the tabs were closed in. + * @param aTagName + * The tag name that will be used when creating the UI items. + * @param aPrefixRestoreAll (defaults to false) + * Whether the 'restore all tabs' item is suffixed or prefixed to the list. + * If suffixed (the default) a separator will be inserted before it. + * @param aRestoreAllLabel (defaults to "appmenu-reopen-all-tabs") + * Which localizable string to use for the 'restore all tabs' item. + * @returns A document fragment with UI items for each recently closed tab. + */ + getTabsFragment( + aWindow, + aTagName, + aPrefixRestoreAll = false, + aRestoreAllLabel = "appmenu-reopen-all-tabs" + ) { + let doc = aWindow.document; + let fragment = doc.createDocumentFragment(); + if (lazy.SessionStore.getClosedTabCount(aWindow) != 0) { + let closedTabs = lazy.SessionStore.getClosedTabData(aWindow); + for (let i = 0; i < closedTabs.length; i++) { + createEntry( + aTagName, + false, + i, + closedTabs[i], + doc, + closedTabs[i].title, + fragment + ); + } + + createRestoreAllEntry( + doc, + fragment, + aPrefixRestoreAll, + false, + aRestoreAllLabel, + closedTabs.length, + aTagName + ); + } + return fragment; + }, + + /** + * Builds up a document fragment of UI items for the recently closed windows. + * @param aWindow + * A window that can be used to create the elements and document fragment. + * @param aTagName + * The tag name that will be used when creating the UI items. + * @param aPrefixRestoreAll (defaults to false) + * Whether the 'restore all windows' item is suffixed or prefixed to the list. + * If suffixed (the default) a separator will be inserted before it. + * @param aRestoreAllLabel (defaults to "appmenu-reopen-all-windows") + * Which localizable string to use for the 'restore all windows' item. + * @returns A document fragment with UI items for each recently closed window. + */ + getWindowsFragment( + aWindow, + aTagName, + aPrefixRestoreAll = false, + aRestoreAllLabel = "appmenu-reopen-all-windows" + ) { + let closedWindowData = lazy.SessionStore.getClosedWindowData(); + let doc = aWindow.document; + let fragment = doc.createDocumentFragment(); + if (closedWindowData.length) { + let menuLabelString = navigatorBundle.GetStringFromName( + "menuUndoCloseWindowLabel" + ); + let menuLabelStringSingleTab = navigatorBundle.GetStringFromName( + "menuUndoCloseWindowSingleTabLabel" + ); + + for (let i = 0; i < closedWindowData.length; i++) { + let undoItem = closedWindowData[i]; + let otherTabsCount = undoItem.tabs.length - 1; + let label = + otherTabsCount == 0 + ? menuLabelStringSingleTab + : lazy.PluralForm.get(otherTabsCount, menuLabelString); + let menuLabel = label + .replace("#1", undoItem.title) + .replace("#2", otherTabsCount); + let selectedTab = undoItem.tabs[undoItem.selected - 1]; + if (!selectedTab) { + continue; + } + createEntry(aTagName, true, i, selectedTab, doc, menuLabel, fragment); + } + + createRestoreAllEntry( + doc, + fragment, + aPrefixRestoreAll, + true, + aRestoreAllLabel, + closedWindowData.length, + aTagName + ); + } + return fragment; + }, + + /** + * Re-open a closed tab and put it to the end of the tab strip. + * Used for a middle click. + * @param aEvent + * The event when the user clicks the menu item + */ + _undoCloseMiddleClick(aEvent) { + if (aEvent.button != 1) { + return; + } + + aEvent.view.undoCloseTab(aEvent.originalTarget.getAttribute("value")); + aEvent.view.gBrowser.moveTabToEnd(); + let ancestorPanel = aEvent.target.closest("panel"); + if (ancestorPanel) { + ancestorPanel.hidePopup(); + } + }, + + get strings() { + delete this.strings; + return (this.strings = new Localization( + ["branding/brand.ftl", "browser/menubar.ftl", "browser/appmenu.ftl"], + true + )); + }, +}; + +/** + * Create a UI entry for a recently closed tab or window. + * @param aTagName + * the tag name that will be used when creating the UI entry + * @param aIsWindowsFragment + * whether or not this entry will represent a closed window + * @param aIndex + * the index of the closed tab + * @param aClosedTab + * the closed tab + * @param aDocument + * a document that can be used to create the entry + * @param aMenuLabel + * the label the created entry will have + * @param aFragment + * the fragment the created entry will be in + */ +function createEntry( + aTagName, + aIsWindowsFragment, + aIndex, + aClosedTab, + aDocument, + aMenuLabel, + aFragment +) { + let element = aDocument.createXULElement(aTagName); + + element.setAttribute("label", aMenuLabel); + if (aClosedTab.image) { + const iconURL = lazy.PlacesUIUtils.getImageURL(aClosedTab.image); + element.setAttribute("image", iconURL); + } + if (!aIsWindowsFragment) { + element.setAttribute("value", aIndex); + } + + if (aTagName == "menuitem") { + element.setAttribute( + "class", + "menuitem-iconic bookmark-item menuitem-with-favicon" + ); + } + + element.setAttribute( + "oncommand", + "undoClose" + (aIsWindowsFragment ? "Window" : "Tab") + "(" + aIndex + ");" + ); + + // Set the targetURI attribute so it will be shown in tooltip. + // SessionStore uses one-based indexes, so we need to normalize them. + let tabData; + tabData = aIsWindowsFragment ? aClosedTab : aClosedTab.state; + let activeIndex = (tabData.index || tabData.entries.length) - 1; + if (activeIndex >= 0 && tabData.entries[activeIndex]) { + element.setAttribute("targetURI", tabData.entries[activeIndex].url); + } + + // Windows don't open in new tabs and menuitems dispatch command events on + // middle click, so we only need to manually handle middle clicks for + // toolbarbuttons. + if (!aIsWindowsFragment && aTagName != "menuitem") { + element.addEventListener( + "click", + RecentlyClosedTabsAndWindowsMenuUtils._undoCloseMiddleClick + ); + } + + if (aIndex == 0) { + element.setAttribute( + "key", + "key_undoClose" + (aIsWindowsFragment ? "Window" : "Tab") + ); + } + + aFragment.appendChild(element); +} + +/** + * Create an entry to restore all closed windows or tabs. + * @param aDocument + * a document that can be used to create the entry + * @param aFragment + * the fragment the created entry will be in + * @param aPrefixRestoreAll + * whether the 'restore all windows' item is suffixed or prefixed to the list + * If suffixed a separator will be inserted before it. + * @param aIsWindowsFragment + * whether or not this entry will represent a closed window + * @param aRestoreAllLabel + * which localizable string to use for the entry + * @param aEntryCount + * the number of elements to be restored by this entry + * @param aTagName + * the tag name that will be used when creating the UI entry + */ +function createRestoreAllEntry( + aDocument, + aFragment, + aPrefixRestoreAll, + aIsWindowsFragment, + aRestoreAllLabel, + aEntryCount, + aTagName +) { + let restoreAllElements = aDocument.createXULElement(aTagName); + restoreAllElements.classList.add("restoreallitem"); + + // We cannot use aDocument.l10n.setAttributes because the menubar label is not + // updated in time and displays a blank string (see Bug 1691553). + restoreAllElements.setAttribute( + "label", + RecentlyClosedTabsAndWindowsMenuUtils.strings.formatValueSync( + aRestoreAllLabel + ) + ); + + restoreAllElements.setAttribute( + "oncommand", + "for (var i = 0; i < " + + aEntryCount + + "; i++) undoClose" + + (aIsWindowsFragment ? "Window" : "Tab") + + "();" + ); + if (aPrefixRestoreAll) { + aFragment.insertBefore(restoreAllElements, aFragment.firstChild); + } else { + aFragment.appendChild(aDocument.createXULElement("menuseparator")); + aFragment.appendChild(restoreAllElements); + } +} diff --git a/browser/components/sessionstore/RunState.sys.mjs b/browser/components/sessionstore/RunState.sys.mjs new file mode 100644 index 0000000000..94f9a86fcd --- /dev/null +++ b/browser/components/sessionstore/RunState.sys.mjs @@ -0,0 +1,92 @@ +/* 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/. */ + +const STATE_STOPPED = 0; +const STATE_RUNNING = 1; +const STATE_QUITTING = 2; +const STATE_CLOSING = 3; +const STATE_CLOSED = 4; + +// We're initially stopped. +var state = STATE_STOPPED; + +/** + * This module keeps track of SessionStore's current run state. We will + * always start out at STATE_STOPPED. After the session was read from disk and + * the initial browser window has loaded we switch to STATE_RUNNING. On the + * first notice that a browser shutdown was granted we switch to STATE_QUITTING. + */ +export var RunState = Object.freeze({ + // If we're stopped then SessionStore hasn't been initialized yet. As soon + // as the session is read from disk and the initial browser window has loaded + // the run state will change to STATE_RUNNING. + get isStopped() { + return state == STATE_STOPPED; + }, + + // STATE_RUNNING is our default mode of operation that we'll spend most of + // the time in. After the session was read from disk and the first browser + // window has loaded we remain running until the browser quits. + get isRunning() { + return state == STATE_RUNNING; + }, + + // We will enter STATE_QUITTING as soon as we receive notice that a browser + // shutdown was granted. SessionStore will use this information to prevent + // us from collecting partial information while the browser is shutting down + // as well as to allow a last single write to disk and block all writes after + // that. + get isQuitting() { + return state >= STATE_QUITTING; + }, + + // We will enter STATE_CLOSING as soon as SessionStore is uninitialized. + // The SessionFile module will know that a last write will happen in this + // state and it can do some necessary cleanup. + get isClosing() { + return state == STATE_CLOSING; + }, + + // We will enter STATE_CLOSED as soon as SessionFile has written to disk for + // the last time before shutdown and will not accept any further writes. + get isClosed() { + return state == STATE_CLOSED; + }, + + // Switch the run state to STATE_RUNNING. This must be called after the + // session was read from, the initial browser window has loaded and we're + // now ready to restore session data. + setRunning() { + if (this.isStopped) { + state = STATE_RUNNING; + } + }, + + // Switch the run state to STATE_CLOSING. This must be called *before* the + // last SessionFile.write() call so that SessionFile knows we're closing and + // can do some last cleanups and write a proper sessionstore.js file. + setClosing() { + if (this.isQuitting) { + state = STATE_CLOSING; + } + }, + + // Switch the run state to STATE_CLOSED. This must be called by SessionFile + // after the last write to disk was accepted and no further writes will be + // allowed. Any writes after this stage will cause exceptions. + setClosed() { + if (this.isClosing) { + state = STATE_CLOSED; + } + }, + + // Switch the run state to STATE_QUITTING. This should be called once we're + // certain that the browser is going away and before we start collecting the + // final window states to save in the session file. + setQuitting() { + if (this.isRunning) { + state = STATE_QUITTING; + } + }, +}); diff --git a/browser/components/sessionstore/SessionCookies.sys.mjs b/browser/components/sessionstore/SessionCookies.sys.mjs new file mode 100644 index 0000000000..baaa5039c7 --- /dev/null +++ b/browser/components/sessionstore/SessionCookies.sys.mjs @@ -0,0 +1,293 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivacyLevel: "resource://gre/modules/sessionstore/PrivacyLevel.sys.mjs", +}); + +const MAX_EXPIRY = Number.MAX_SAFE_INTEGER; + +/** + * The external API implemented by the SessionCookies module. + */ +export var SessionCookies = Object.freeze({ + collect() { + return SessionCookiesInternal.collect(); + }, + + restore(cookies) { + SessionCookiesInternal.restore(cookies); + }, +}); + +/** + * The internal API. + */ +var SessionCookiesInternal = { + /** + * Stores whether we're initialized, yet. + */ + _initialized: false, + + /** + * Retrieve an array of all stored session cookies. + */ + collect() { + this._ensureInitialized(); + return CookieStore.toArray(); + }, + + /** + * Restores a given list of session cookies. + */ + restore(cookies) { + for (let cookie of cookies) { + let expiry = "expiry" in cookie ? cookie.expiry : MAX_EXPIRY; + let exists = false; + try { + exists = Services.cookies.cookieExists( + cookie.host, + cookie.path || "", + cookie.name || "", + cookie.originAttributes || {} + ); + } catch (ex) { + Cu.reportError( + `CookieService::CookieExists failed with error '${ex}' for '${JSON.stringify( + cookie + )}'.` + ); + } + if (!exists) { + try { + Services.cookies.add( + cookie.host, + cookie.path || "", + cookie.name || "", + cookie.value, + !!cookie.secure, + !!cookie.httponly, + /* isSession = */ true, + expiry, + cookie.originAttributes || {}, + cookie.sameSite || Ci.nsICookie.SAMESITE_NONE, + cookie.schemeMap || Ci.nsICookie.SCHEME_HTTPS + ); + } catch (ex) { + Cu.reportError( + `CookieService::Add failed with error '${ex}' for cookie ${JSON.stringify( + cookie + )}.` + ); + } + } + } + }, + + /** + * Handles observers notifications that are sent whenever cookies are added, + * changed, or removed. Ensures that the storage is updated accordingly. + */ + observe(subject, topic, data) { + switch (data) { + case "added": + this._addCookie(subject); + break; + case "changed": + this._updateCookie(subject); + break; + case "deleted": + this._removeCookie(subject); + break; + case "cleared": + CookieStore.clear(); + break; + case "batch-deleted": + this._removeCookies(subject); + break; + default: + throw new Error("Unhandled session-cookie-changed notification."); + } + }, + + /** + * If called for the first time in a session, iterates all cookies in the + * cookies service and puts them into the store if they're session cookies. + */ + _ensureInitialized() { + if (this._initialized) { + return; + } + this._reloadCookies(); + this._initialized = true; + Services.obs.addObserver(this, "session-cookie-changed"); + + // Listen for privacy level changes to reload cookies when needed. + Services.prefs.addObserver("browser.sessionstore.privacy_level", () => { + this._reloadCookies(); + }); + }, + + /** + * Adds a given cookie to the store. + */ + _addCookie(cookie) { + cookie.QueryInterface(Ci.nsICookie); + + // Store only session cookies, obey the privacy level. + if (cookie.isSession && lazy.PrivacyLevel.canSave(cookie.isSecure)) { + CookieStore.add(cookie); + } + }, + + /** + * Updates a given cookie. + */ + _updateCookie(cookie) { + cookie.QueryInterface(Ci.nsICookie); + + // Store only session cookies, obey the privacy level. + if (cookie.isSession && lazy.PrivacyLevel.canSave(cookie.isSecure)) { + CookieStore.add(cookie); + } else { + CookieStore.delete(cookie); + } + }, + + /** + * Removes a given cookie from the store. + */ + _removeCookie(cookie) { + cookie.QueryInterface(Ci.nsICookie); + + if (cookie.isSession) { + CookieStore.delete(cookie); + } + }, + + /** + * Removes a given list of cookies from the store. + */ + _removeCookies(cookies) { + for (let i = 0; i < cookies.length; i++) { + this._removeCookie(cookies.queryElementAt(i, Ci.nsICookie)); + } + }, + + /** + * Iterates all cookies in the cookies service and puts them into the store + * if they're session cookies. Obeys the user's chosen privacy level. + */ + _reloadCookies() { + CookieStore.clear(); + + // Bail out if we're not supposed to store cookies at all. + if (!lazy.PrivacyLevel.canSave(false)) { + return; + } + + for (let cookie of Services.cookies.sessionCookies) { + this._addCookie(cookie); + } + }, +}; + +/** + * The internal storage that keeps track of session cookies. + */ +var CookieStore = { + /** + * The internal map holding all known session cookies. + */ + _entries: new Map(), + + /** + * Stores a given cookie. + * + * @param cookie + * The nsICookie object to add to the storage. + */ + add(cookie) { + let jscookie = { host: cookie.host, value: cookie.value }; + + // Only add properties with non-default values to save a few bytes. + if (cookie.path) { + jscookie.path = cookie.path; + } + + if (cookie.name) { + jscookie.name = cookie.name; + } + + if (cookie.isSecure) { + jscookie.secure = true; + } + + if (cookie.isHttpOnly) { + jscookie.httponly = true; + } + + if (cookie.expiry < MAX_EXPIRY) { + jscookie.expiry = cookie.expiry; + } + + if (cookie.originAttributes) { + jscookie.originAttributes = cookie.originAttributes; + } + + if (cookie.sameSite) { + jscookie.sameSite = cookie.sameSite; + } + + if (cookie.schemeMap) { + jscookie.schemeMap = cookie.schemeMap; + } + + this._entries.set(this._getKeyForCookie(cookie), jscookie); + }, + + /** + * Removes a given cookie. + * + * @param cookie + * The nsICookie object to be removed from storage. + */ + delete(cookie) { + this._entries.delete(this._getKeyForCookie(cookie)); + }, + + /** + * Removes all cookies. + */ + clear() { + this._entries.clear(); + }, + + /** + * Return all cookies as an array. + */ + toArray() { + return [...this._entries.values()]; + }, + + /** + * Returns the key needed to properly store and identify a given cookie. + * A cookie is uniquely identified by the combination of its host, name, + * path, and originAttributes properties. + * + * @param cookie + * The nsICookie object to compute a key for. + * @return string + */ + _getKeyForCookie(cookie) { + return JSON.stringify({ + host: cookie.host, + name: cookie.name, + path: cookie.path, + attr: ChromeUtils.originAttributesToSuffix(cookie.originAttributes), + }); + }, +}; diff --git a/browser/components/sessionstore/SessionFile.sys.mjs b/browser/components/sessionstore/SessionFile.sys.mjs new file mode 100644 index 0000000000..b74dbc621c --- /dev/null +++ b/browser/components/sessionstore/SessionFile.sys.mjs @@ -0,0 +1,469 @@ +/* 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/. */ + +/** + * Implementation of all the disk I/O required by the session store. + * This is a private API, meant to be used only by the session store. + * It will change. Do not use it for any other purpose. + * + * Note that this module depends on SessionWriter and that it enqueues its I/O + * requests and never attempts to simultaneously execute two I/O requests on + * the files used by this module from two distinct threads. + * Otherwise, we could encounter bugs, especially under Windows, + * e.g. if a request attempts to write sessionstore.js while + * another attempts to copy that file. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + RunState: "resource:///modules/sessionstore/RunState.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + SessionWriter: "resource:///modules/sessionstore/SessionWriter.sys.mjs", +}); + +const PREF_UPGRADE_BACKUP = "browser.sessionstore.upgradeBackup.latestBuildID"; +const PREF_MAX_UPGRADE_BACKUPS = + "browser.sessionstore.upgradeBackup.maxUpgradeBackups"; + +const PREF_MAX_SERIALIZE_BACK = "browser.sessionstore.max_serialize_back"; +const PREF_MAX_SERIALIZE_FWD = "browser.sessionstore.max_serialize_forward"; + +export var SessionFile = { + /** + * Read the contents of the session file, asynchronously. + */ + read() { + return SessionFileInternal.read(); + }, + /** + * Write the contents of the session file, asynchronously. + * @param aData - May get changed on shutdown. + */ + write(aData) { + return SessionFileInternal.write(aData); + }, + /** + * Wipe the contents of the session file, asynchronously. + */ + wipe() { + return SessionFileInternal.wipe(); + }, + + /** + * Return the paths to the files used to store, backup, etc. + * the state of the file. + */ + get Paths() { + return SessionFileInternal.Paths; + }, +}; + +Object.freeze(SessionFile); + +const profileDir = PathUtils.profileDir; + +var SessionFileInternal = { + Paths: Object.freeze({ + // The path to the latest version of sessionstore written during a clean + // shutdown. After startup, it is renamed `cleanBackup`. + clean: PathUtils.join(profileDir, "sessionstore.jsonlz4"), + + // The path at which we store the previous version of `clean`. Updated + // whenever we successfully load from `clean`. + cleanBackup: PathUtils.join( + profileDir, + "sessionstore-backups", + "previous.jsonlz4" + ), + + // The directory containing all sessionstore backups. + backups: PathUtils.join(profileDir, "sessionstore-backups"), + + // The path to the latest version of the sessionstore written + // during runtime. Generally, this file contains more + // privacy-sensitive information than |clean|, and this file is + // therefore removed during clean shutdown. This file is designed to protect + // against crashes / sudden shutdown. + recovery: PathUtils.join( + profileDir, + "sessionstore-backups", + "recovery.jsonlz4" + ), + + // The path to the previous version of the sessionstore written + // during runtime (e.g. 15 seconds before recovery). In case of a + // clean shutdown, this file is removed. Generally, this file + // contains more privacy-sensitive information than |clean|, and + // this file is therefore removed during clean shutdown. This + // file is designed to protect against crashes that are nasty + // enough to corrupt |recovery|. + recoveryBackup: PathUtils.join( + profileDir, + "sessionstore-backups", + "recovery.baklz4" + ), + + // The path to a backup created during an upgrade of Firefox. + // Having this backup protects the user essentially from bugs in + // Firefox or add-ons, especially for users of Nightly. This file + // does not contain any information more sensitive than |clean|. + upgradeBackupPrefix: PathUtils.join( + profileDir, + "sessionstore-backups", + "upgrade.jsonlz4-" + ), + + // The path to the backup of the version of the session store used + // during the latest upgrade of Firefox. During load/recovery, + // this file should be used if both |path|, |backupPath| and + // |latestStartPath| are absent/incorrect. May be "" if no + // upgrade backup has ever been performed. This file does not + // contain any information more sensitive than |clean|. + get upgradeBackup() { + let latestBackupID = SessionFileInternal.latestUpgradeBackupID; + if (!latestBackupID) { + return ""; + } + return this.upgradeBackupPrefix + latestBackupID; + }, + + // The path to a backup created during an upgrade of Firefox. + // Having this backup protects the user essentially from bugs in + // Firefox, especially for users of Nightly. + get nextUpgradeBackup() { + return this.upgradeBackupPrefix + Services.appinfo.platformBuildID; + }, + + /** + * The order in which to search for a valid sessionstore file. + */ + get loadOrder() { + // If `clean` exists and has been written without corruption during + // the latest shutdown, we need to use it. + // + // Otherwise, `recovery` and `recoveryBackup` represent the most + // recent state of the session store. + // + // Finally, if nothing works, fall back to the last known state + // that can be loaded (`cleanBackup`) or, if available, to the + // backup performed during the latest upgrade. + let order = ["clean", "recovery", "recoveryBackup", "cleanBackup"]; + if (SessionFileInternal.latestUpgradeBackupID) { + // We have an upgradeBackup + order.push("upgradeBackup"); + } + return order; + }, + }), + + // Number of attempted calls to `write`. + // Note that we may have _attempts > _successes + _failures, + // if attempts never complete. + // Used for error reporting. + _attempts: 0, + + // Number of successful calls to `write`. + // Used for error reporting. + _successes: 0, + + // Number of failed calls to `write`. + // Used for error reporting. + _failures: 0, + + // `true` once we have initialized SessionWriter. + _initialized: false, + + // A string that will be set to the session file name part that was read from + // disk. It will be available _after_ a session file read() is done. + _readOrigin: null, + + // `true` if the old, uncompressed, file format was used to read from disk, as + // a fallback mechanism. + _usingOldExtension: false, + + // The ID of the latest version of Gecko for which we have an upgrade backup + // or |undefined| if no upgrade backup was ever written. + get latestUpgradeBackupID() { + try { + return Services.prefs.getCharPref(PREF_UPGRADE_BACKUP); + } catch (ex) { + return undefined; + } + }, + + async _readInternal(useOldExtension) { + let result; + let noFilesFound = true; + this._usingOldExtension = useOldExtension; + + // Attempt to load by order of priority from the various backups + for (let key of this.Paths.loadOrder) { + let corrupted = false; + let exists = true; + try { + let path; + let startMs = Date.now(); + + let options = {}; + if (useOldExtension) { + path = this.Paths[key] + .replace("jsonlz4", "js") + .replace("baklz4", "bak"); + } else { + path = this.Paths[key]; + options.decompress = true; + } + let source = await IOUtils.readUTF8(path, options); + let parsed = JSON.parse(source); + + if (parsed._cachedObjs) { + try { + let cacheMap = new Map(parsed._cachedObjs); + for (let win of parsed.windows.concat( + parsed._closedWindows || [] + )) { + for (let tab of win.tabs.concat(win._closedTabs || [])) { + tab.image = cacheMap.get(tab.image) || tab.image; + } + } + } catch (e) { + // This is temporary code to clean up after the backout of bug + // 1546847. Just in case there are problems in the format of + // the parsed data, continue on. Favicons might be broken, but + // the session will at least be recovered + Cu.reportError(e); + } + } + + if ( + !lazy.SessionStore.isFormatVersionCompatible( + parsed.version || [ + "sessionrestore", + 0, + ] /* fallback for old versions*/ + ) + ) { + // Skip sessionstore files that we don't understand. + Cu.reportError( + "Cannot extract data from Session Restore file " + + path + + ". Wrong format/version: " + + JSON.stringify(parsed.version) + + "." + ); + continue; + } + result = { + origin: key, + source, + parsed, + useOldExtension, + }; + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE") + .add(false); + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_READ_FILE_MS") + .add(Date.now() - startMs); + break; + } catch (ex) { + if (DOMException.isInstance(ex) && ex.name == "NotFoundError") { + exists = false; + } else if ( + DOMException.isInstance(ex) && + ex.name == "NotAllowedError" + ) { + // The file might be inaccessible due to wrong permissions + // or similar failures. We'll just count it as "corrupted". + console.error("Could not read session file ", ex); + corrupted = true; + } else if (ex instanceof SyntaxError) { + console.error( + "Corrupt session file (invalid JSON found) ", + ex, + ex.stack + ); + // File is corrupted, try next file + corrupted = true; + } + } finally { + if (exists) { + noFilesFound = false; + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE") + .add(corrupted); + } + } + } + return { result, noFilesFound }; + }, + + // Find the correct session file and read it. + async read() { + // Load session files with lz4 compression. + let { result, noFilesFound } = await this._readInternal(false); + if (!result) { + // No result? Probably because of migration, let's + // load uncompressed session files. + let r = await this._readInternal(true); + result = r.result; + } + + // All files are corrupted if files found but none could deliver a result. + let allCorrupt = !noFilesFound && !result; + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_ALL_FILES_CORRUPT") + .add(allCorrupt); + + if (!result) { + // If everything fails, start with an empty session. + result = { + origin: "empty", + source: "", + parsed: null, + useOldExtension: false, + }; + } + this._readOrigin = result.origin; + + result.noFilesFound = noFilesFound; + + return result; + }, + + // Initialize SessionWriter and return it as a resolved promise. + getWriter() { + if (!this._initialized) { + if (!this._readOrigin) { + return Promise.reject( + "SessionFileInternal.getWriter() called too early! Please read the session file from disk first." + ); + } + + this._initialized = true; + lazy.SessionWriter.init( + this._readOrigin, + this._usingOldExtension, + this.Paths, + { + maxUpgradeBackups: Services.prefs.getIntPref( + PREF_MAX_UPGRADE_BACKUPS, + 3 + ), + maxSerializeBack: Services.prefs.getIntPref( + PREF_MAX_SERIALIZE_BACK, + 10 + ), + maxSerializeForward: Services.prefs.getIntPref( + PREF_MAX_SERIALIZE_FWD, + -1 + ), + } + ); + } + + return Promise.resolve(lazy.SessionWriter); + }, + + write(aData) { + if (lazy.RunState.isClosed) { + return Promise.reject(new Error("SessionFile is closed")); + } + + let isFinalWrite = false; + if (lazy.RunState.isClosing) { + // If shutdown has started, we will want to stop receiving + // write instructions. + isFinalWrite = true; + lazy.RunState.setClosed(); + } + + let performShutdownCleanup = + isFinalWrite && !lazy.SessionStore.willAutoRestore; + + this._attempts++; + let options = { isFinalWrite, performShutdownCleanup }; + let promise = this.getWriter().then(writer => writer.write(aData, options)); + + // Wait until the write is done. + promise = promise.then( + msg => { + // Record how long the write took. + this._recordTelemetry(msg.telemetry); + this._successes++; + if (msg.result.upgradeBackup) { + // We have just completed a backup-on-upgrade, store the information + // in preferences. + Services.prefs.setCharPref( + PREF_UPGRADE_BACKUP, + Services.appinfo.platformBuildID + ); + } + }, + err => { + // Catch and report any errors. + console.error("Could not write session state file ", err, err.stack); + this._failures++; + // By not doing anything special here we ensure that |promise| cannot + // be rejected anymore. The shutdown/cleanup code at the end of the + // function will thus always be executed. + } + ); + + // Ensure that we can write sessionstore.js cleanly before the profile + // becomes unaccessible. + IOUtils.profileBeforeChange.addBlocker( + "SessionFile: Finish writing Session Restore data", + promise, + { + fetchState: () => ({ + options, + attempts: this._attempts, + successes: this._successes, + failures: this._failures, + }), + } + ); + + // This code will always be executed because |promise| can't fail anymore. + // We ensured that by having a reject handler that reports the failure but + // doesn't forward the rejection. + return promise.then(() => { + // Remove the blocker, no matter if writing failed or not. + IOUtils.profileBeforeChange.removeBlocker(promise); + + if (isFinalWrite) { + Services.obs.notifyObservers( + null, + "sessionstore-final-state-write-complete" + ); + } + }); + }, + + async wipe() { + const writer = await this.getWriter(); + await writer.wipe(); + // After a wipe, we need to make sure to re-initialize upon the next read(), + // because the state variables as sent to the writer have changed. + this._initialized = false; + }, + + _recordTelemetry(telemetry) { + for (let id of Object.keys(telemetry)) { + let value = telemetry[id]; + let samples = []; + if (Array.isArray(value)) { + samples.push(...value); + } else { + samples.push(value); + } + let histogram = Services.telemetry.getHistogramById(id); + for (let sample of samples) { + histogram.add(sample); + } + } + }, +}; diff --git a/browser/components/sessionstore/SessionMigration.sys.mjs b/browser/components/sessionstore/SessionMigration.sys.mjs new file mode 100644 index 0000000000..26e1e27d50 --- /dev/null +++ b/browser/components/sessionstore/SessionMigration.sys.mjs @@ -0,0 +1,92 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", +}); + +var SessionMigrationInternal = { + /** + * Convert the original session restore state into a minimal state. It will + * only contain: + * - open windows + * - with tabs + * - with history entries with only title, url, triggeringPrincipal + * - with pinned state + * - with tab group info (hidden + group id) + * - with selected tab info + * - with selected window info + * + * The complete state is then wrapped into the "about:welcomeback" page as + * form field info to be restored when restoring the state. + */ + convertState(aStateObj) { + let state = { + selectedWindow: aStateObj.selectedWindow, + _closedWindows: [], + }; + state.windows = aStateObj.windows.map(function(oldWin) { + var win = { extData: {} }; + win.tabs = oldWin.tabs.map(function(oldTab) { + var tab = {}; + // Keep only titles, urls and triggeringPrincipals for history entries + tab.entries = oldTab.entries.map(function(entry) { + return { + url: entry.url, + triggeringPrincipal_base64: entry.triggeringPrincipal_base64, + title: entry.title, + }; + }); + tab.index = oldTab.index; + tab.hidden = oldTab.hidden; + tab.pinned = oldTab.pinned; + return tab; + }); + win.selected = oldWin.selected; + win._closedTabs = []; + return win; + }); + let url = "about:welcomeback"; + let formdata = { id: { sessionData: state }, url }; + let entry = { + url, + triggeringPrincipal_base64: lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL, + }; + return { windows: [{ tabs: [{ entries: [entry], formdata }] }] }; + }, + /** + * Asynchronously read session restore state (JSON) from a path + */ + readState(aPath) { + return IOUtils.readJSON(aPath, { decompress: true }); + }, + /** + * Asynchronously write session restore state as JSON to a path + */ + writeState(aPath, aState) { + return IOUtils.writeJSON(aPath, aState, { + compress: true, + tmpPath: `${aPath}.tmp`, + }); + }, +}; + +export var SessionMigration = { + /** + * Migrate a limited set of session data from one path to another. + */ + migrate(aFromPath, aToPath) { + return (async function() { + let inState = await SessionMigrationInternal.readState(aFromPath); + let outState = SessionMigrationInternal.convertState(inState); + // Unfortunately, we can't use SessionStore's own SessionFile to + // write out the data because it has a dependency on the profile dir + // being known. When the migration runs, there is no guarantee that + // that's true. + await SessionMigrationInternal.writeState(aToPath, outState); + })(); + }, +}; diff --git a/browser/components/sessionstore/SessionSaver.sys.mjs b/browser/components/sessionstore/SessionSaver.sys.mjs new file mode 100644 index 0000000000..20521bf326 --- /dev/null +++ b/browser/components/sessionstore/SessionSaver.sys.mjs @@ -0,0 +1,405 @@ +/* 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/. */ + +import { + cancelIdleCallback, + clearTimeout, + requestIdleCallback, + setTimeout, +} from "resource://gre/modules/Timer.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + RunState: "resource:///modules/sessionstore/RunState.sys.mjs", + SessionFile: "resource:///modules/sessionstore/SessionFile.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +/* + * Minimal interval between two save operations (in milliseconds). + * + * To save system resources, we generally do not save changes immediately when + * a change is detected. Rather, we wait a little to see if this change is + * followed by other changes, in which case only the last write is necessary. + * This delay is defined by "browser.sessionstore.interval". + * + * Furthermore, when the user is not actively using the computer, webpages + * may still perform changes that require (re)writing to sessionstore, e.g. + * updating Session Cookies or DOM Session Storage, or refreshing, etc. We + * expect that these changes are much less critical to the user and do not + * need to be saved as often. In such cases, we increase the delay to + * "browser.sessionstore.interval.idle". + * + * When the user returns to the computer, if a save is pending, we reschedule + * it to happen soon, with "browser.sessionstore.interval". + */ +const PREF_INTERVAL_ACTIVE = "browser.sessionstore.interval"; +const PREF_INTERVAL_IDLE = "browser.sessionstore.interval.idle"; +const PREF_IDLE_DELAY = "browser.sessionstore.idleDelay"; + +// Notify observers about a given topic with a given subject. +function notify(subject, topic) { + Services.obs.notifyObservers(subject, topic); +} + +// TelemetryStopwatch helper functions. +function stopWatch(method) { + return function(...histograms) { + for (let hist of histograms) { + TelemetryStopwatch[method]("FX_SESSION_RESTORE_" + hist); + } + }; +} + +var stopWatchStart = stopWatch("start"); +var stopWatchFinish = stopWatch("finish"); + +/** + * The external API implemented by the SessionSaver module. + */ +export var SessionSaver = Object.freeze({ + /** + * Immediately saves the current session to disk. + */ + run() { + return SessionSaverInternal.run(); + }, + + /** + * Saves the current session to disk delayed by a given amount of time. Should + * another delayed run be scheduled already, we will ignore the given delay + * and state saving may occur a little earlier. + */ + runDelayed() { + SessionSaverInternal.runDelayed(); + }, + + /** + * Sets the last save time to the current time. This will cause us to wait for + * at least the configured interval when runDelayed() is called next. + */ + updateLastSaveTime() { + SessionSaverInternal.updateLastSaveTime(); + }, + + /** + * Cancels all pending session saves. + */ + cancel() { + SessionSaverInternal.cancel(); + }, +}); + +/** + * The internal API. + */ +var SessionSaverInternal = { + /** + * The timeout ID referencing an active timer for a delayed save. When no + * save is pending, this is null. + */ + _timeoutID: null, + + /** + * The idle callback ID referencing an active idle callback. When no idle + * callback is pending, this is null. + * */ + _idleCallbackID: null, + + /** + * A timestamp that keeps track of when we saved the session last. We will + * this to determine the correct interval between delayed saves to not deceed + * the configured session write interval. + */ + _lastSaveTime: 0, + + /** + * `true` if the user has been idle for at least + * `SessionSaverInternal._intervalWhileIdle` ms. Idleness is computed + * with `nsIUserIdleService`. + */ + _isIdle: false, + + /** + * `true` if the user was idle when we last scheduled a delayed save. + * See `_isIdle` for details on idleness. + */ + _wasIdle: false, + + /** + * Minimal interval between two save operations (in ms), while the user + * is active. + */ + _intervalWhileActive: null, + + /** + * Minimal interval between two save operations (in ms), while the user + * is idle. + */ + _intervalWhileIdle: null, + + /** + * How long before we assume that the user is idle (ms). + */ + _idleDelay: null, + + /** + * Immediately saves the current session to disk. + */ + run() { + return this._saveState(true /* force-update all windows */); + }, + + /** + * Saves the current session to disk delayed by a given amount of time. Should + * another delayed run be scheduled already, we will ignore the given delay + * and state saving may occur a little earlier. + * + * @param delay (optional) + * The minimum delay in milliseconds to wait for until we collect and + * save the current session. + */ + runDelayed(delay = 2000) { + // Bail out if there's a pending run. + if (this._timeoutID) { + return; + } + + // Interval until the next disk operation is allowed. + let interval = this._isIdle + ? this._intervalWhileIdle + : this._intervalWhileActive; + delay = Math.max(this._lastSaveTime + interval - Date.now(), delay, 0); + + // Schedule a state save. + this._wasIdle = this._isIdle; + this._timeoutID = setTimeout(() => { + // Execute _saveStateAsync when we have idle time. + let saveStateAsyncWhenIdle = () => { + this._saveStateAsync(); + }; + + this._idleCallbackID = requestIdleCallback(saveStateAsyncWhenIdle); + }, delay); + }, + + /** + * Sets the last save time to the current time. This will cause us to wait for + * at least the configured interval when runDelayed() is called next. + */ + updateLastSaveTime() { + this._lastSaveTime = Date.now(); + }, + + /** + * Cancels all pending session saves. + */ + cancel() { + clearTimeout(this._timeoutID); + this._timeoutID = null; + cancelIdleCallback(this._idleCallbackID); + this._idleCallbackID = null; + }, + + /** + * Observe idle/ active notifications. + */ + observe(subject, topic, data) { + switch (topic) { + case "idle": + this._isIdle = true; + break; + case "active": + this._isIdle = false; + if (this._timeoutID && this._wasIdle) { + // A state save has been scheduled while we were idle. + // Replace it by an active save. + clearTimeout(this._timeoutID); + this._timeoutID = null; + this.runDelayed(); + } + break; + default: + throw new Error(`Unexpected change value ${topic}`); + } + }, + + /** + * Saves the current session state. Collects data and writes to disk. + * + * @param forceUpdateAllWindows (optional) + * Forces us to recollect data for all windows and will bypass and + * update the corresponding caches. + */ + _saveState(forceUpdateAllWindows = false) { + // Cancel any pending timeouts. + this.cancel(); + + if (lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) { + // Don't save (or even collect) anything in permanent private + // browsing mode + + this.updateLastSaveTime(); + return Promise.resolve(); + } + + stopWatchStart("COLLECT_DATA_MS"); + let state = lazy.SessionStore.getCurrentState(forceUpdateAllWindows); + lazy.PrivacyFilter.filterPrivateWindowsAndTabs(state); + + // Make sure we only write worth saving tabs to disk. + lazy.SessionStore.keepOnlyWorthSavingTabs(state); + + // Make sure that we keep the previous session if we started with a single + // private window and no non-private windows have been opened, yet. + if (state.deferredInitialState) { + state.windows = state.deferredInitialState.windows || []; + delete state.deferredInitialState; + } + + if (AppConstants.platform != "macosx") { + // We want to restore closed windows that are marked with _shouldRestore. + // We're doing this here because we want to control this only when saving + // the file. + while (state._closedWindows.length) { + let i = state._closedWindows.length - 1; + + if (!state._closedWindows[i]._shouldRestore) { + // We only need to go until _shouldRestore + // is falsy since we're going in reverse. + break; + } + + delete state._closedWindows[i]._shouldRestore; + state.windows.unshift(state._closedWindows.pop()); + } + } + + // Clear cookies and storage on clean shutdown. + this._maybeClearCookiesAndStorage(state); + + stopWatchFinish("COLLECT_DATA_MS"); + return this._writeState(state); + }, + + /** + * Purges cookies and DOMSessionStorage data from the session on clean + * shutdown, only if requested by the user's preferences. + */ + _maybeClearCookiesAndStorage(state) { + // Only do this on shutdown. + if (!lazy.RunState.isClosing) { + return; + } + + // Don't clear when restarting. + if ( + Services.prefs.getBoolPref("browser.sessionstore.resume_session_once") + ) { + return; + } + let sanitizeCookies = + Services.prefs.getBoolPref("privacy.sanitize.sanitizeOnShutdown") && + Services.prefs.getBoolPref("privacy.clearOnShutdown.cookies"); + + if (sanitizeCookies) { + // Remove cookies. + delete state.cookies; + + // Remove DOMSessionStorage data. + for (let window of state.windows) { + for (let tab of window.tabs) { + delete tab.storage; + } + } + } + }, + + /** + * Saves the current session state. Collects data asynchronously and calls + * _saveState() to collect data again (with a cache hit rate of hopefully + * 100%) and write to disk afterwards. + */ + _saveStateAsync() { + // Allow scheduling delayed saves again. + this._timeoutID = null; + + // Write to disk. + this._saveState(); + }, + + /** + * Write the given state object to disk. + */ + _writeState(state) { + // We update the time stamp before writing so that we don't write again + // too soon, if saving is requested before the write completes. Without + // this update we may save repeatedly if actions cause a runDelayed + // before writing has completed. See Bug 902280 + this.updateLastSaveTime(); + + // Write (atomically) to a session file, using a tmp file. Once the session + // file is successfully updated, save the time stamp of the last save and + // notify the observers. + return lazy.SessionFile.write(state).then(() => { + this.updateLastSaveTime(); + notify(null, "sessionstore-state-write-complete"); + }, console.error); + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + SessionSaverInternal, + "_intervalWhileActive", + PREF_INTERVAL_ACTIVE, + 15000 /* 15 seconds */, + () => { + // Cancel any pending runs and call runDelayed() with + // zero to apply the newly configured interval. + SessionSaverInternal.cancel(); + SessionSaverInternal.runDelayed(0); + } +); + +XPCOMUtils.defineLazyPreferenceGetter( + SessionSaverInternal, + "_intervalWhileIdle", + PREF_INTERVAL_IDLE, + 3600000 /* 1 h */ +); + +XPCOMUtils.defineLazyPreferenceGetter( + SessionSaverInternal, + "_idleDelay", + PREF_IDLE_DELAY, + 180000 /* 3 minutes */, + (key, previous, latest) => { + // Update the idle observer for the new `PREF_IDLE_DELAY` value. Here we need + // to re-fetch the service instead of the original one in use; This is for a + // case that the Mock service in the unit test needs to be fetched to + // replace the original one. + var idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService( + Ci.nsIUserIdleService + ); + if (previous != undefined) { + idleService.removeIdleObserver(SessionSaverInternal, previous); + } + if (latest != undefined) { + idleService.addIdleObserver(SessionSaverInternal, latest); + } + } +); + +var idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService( + Ci.nsIUserIdleService +); +idleService.addIdleObserver( + SessionSaverInternal, + SessionSaverInternal._idleDelay +); diff --git a/browser/components/sessionstore/SessionStartup.sys.mjs b/browser/components/sessionstore/SessionStartup.sys.mjs new file mode 100644 index 0000000000..37d7bf387e --- /dev/null +++ b/browser/components/sessionstore/SessionStartup.sys.mjs @@ -0,0 +1,420 @@ +/* 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/. */ + +/** + * Session Storage and Restoration + * + * Overview + * This service reads user's session file at startup, and makes a determination + * as to whether the session should be restored. It will restore the session + * under the circumstances described below. If the auto-start Private Browsing + * mode is active, however, the session is never restored. + * + * Crash Detection + * The CrashMonitor is used to check if the final session state was successfully + * written at shutdown of the last session. If we did not reach + * 'sessionstore-final-state-write-complete', then it's assumed that the browser + * has previously crashed and we should restore the session. + * + * Forced Restarts + * In the event that a restart is required due to application update or extension + * installation, set the browser.sessionstore.resume_session_once pref to true, + * and the session will be restored the next time the browser starts. + * + * Always Resume + * This service will always resume the session if the integer pref + * browser.startup.page is set to 3. + */ + +/* :::::::: Constants and Helpers ::::::::::::::: */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SessionFile: "resource:///modules/sessionstore/SessionFile.sys.mjs", + StartupPerformance: + "resource:///modules/sessionstore/StartupPerformance.sys.mjs", +}); +ChromeUtils.defineModuleGetter( + lazy, + "CrashMonitor", + "resource://gre/modules/CrashMonitor.jsm" +); + +const STATE_RUNNING_STR = "running"; + +const TYPE_NO_SESSION = 0; +const TYPE_RECOVER_SESSION = 1; +const TYPE_RESUME_SESSION = 2; +const TYPE_DEFER_SESSION = 3; + +// 'browser.startup.page' preference value to resume the previous session. +const BROWSER_STARTUP_RESUME_SESSION = 3; + +function warning(msg, exception) { + let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + consoleMsg.init( + msg, + exception.fileName, + null, + exception.lineNumber, + 0, + Ci.nsIScriptError.warningFlag, + "component javascript" + ); + Services.console.logMessage(consoleMsg); +} + +var gOnceInitializedDeferred = (function() { + let deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + return deferred; +})(); + +/* :::::::: The Service ::::::::::::::: */ + +export var SessionStartup = { + NO_SESSION: TYPE_NO_SESSION, + RECOVER_SESSION: TYPE_RECOVER_SESSION, + RESUME_SESSION: TYPE_RESUME_SESSION, + DEFER_SESSION: TYPE_DEFER_SESSION, + + // The state to restore at startup. + _initialState: null, + _sessionType: null, + _initialized: false, + + // Stores whether the previous session crashed. + _previousSessionCrashed: null, + + _resumeSessionEnabled: null, + + /* ........ Global Event Handlers .............. */ + + /** + * Initialize the component + */ + init() { + Services.obs.notifyObservers(null, "sessionstore-init-started"); + lazy.StartupPerformance.init(); + + // do not need to initialize anything in auto-started private browsing sessions + if (lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) { + this._initialized = true; + gOnceInitializedDeferred.resolve(); + return; + } + + if ( + Services.prefs.getBoolPref( + "browser.sessionstore.resuming_after_os_restart" + ) + ) { + if (!Services.appinfo.restartedByOS) { + // We had set resume_session_once in order to resume after an OS restart, + // but we aren't automatically started by the OS (or else appinfo.restartedByOS + // would have been set). Therefore we should clear resume_session_once + // to avoid forcing a resume for a normal startup. + Services.prefs.setBoolPref( + "browser.sessionstore.resume_session_once", + false + ); + } + Services.prefs.setBoolPref( + "browser.sessionstore.resuming_after_os_restart", + false + ); + } + + lazy.SessionFile.read().then( + this._onSessionFileRead.bind(this), + console.error + ); + }, + + // Wrap a string as a nsISupports. + _createSupportsString(data) { + let string = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + string.data = data; + return string; + }, + + /** + * Complete initialization once the Session File has been read. + * + * @param source The Session State string read from disk. + * @param parsed The object obtained by parsing |source| as JSON. + */ + _onSessionFileRead({ source, parsed, noFilesFound }) { + this._initialized = true; + + // Let observers modify the state before it is used + let supportsStateString = this._createSupportsString(source); + Services.obs.notifyObservers( + supportsStateString, + "sessionstore-state-read" + ); + let stateString = supportsStateString.data; + + if (stateString != source) { + // The session has been modified by an add-on, reparse. + try { + this._initialState = JSON.parse(stateString); + } catch (ex) { + // That's not very good, an add-on has rewritten the initial + // state to something that won't parse. + warning("Observer rewrote the state to something that won't parse", ex); + } + } else { + // No need to reparse + this._initialState = parsed; + } + + if (this._initialState == null) { + // No valid session found. + this._sessionType = this.NO_SESSION; + Services.obs.notifyObservers(null, "sessionstore-state-finalized"); + gOnceInitializedDeferred.resolve(); + return; + } + + let initialState = this._initialState; + Services.tm.idleDispatchToMainThread(() => { + let pinnedTabCount = initialState.windows.reduce((winAcc, win) => { + return ( + winAcc + + win.tabs.reduce((tabAcc, tab) => { + return tabAcc + (tab.pinned ? 1 : 0); + }, 0) + ); + }, 0); + Services.telemetry.scalarSetMaximum( + "browser.engagement.max_concurrent_tab_pinned_count", + pinnedTabCount + ); + }, 60000); + + // If this is a normal restore then throw away any previous session. + if (!this.isAutomaticRestoreEnabled() && this._initialState) { + delete this._initialState.lastSessionState; + } + + lazy.CrashMonitor.previousCheckpoints.then(checkpoints => { + if (checkpoints) { + // If the previous session finished writing the final state, we'll + // assume there was no crash. + this._previousSessionCrashed = !checkpoints[ + "sessionstore-final-state-write-complete" + ]; + } else if (noFilesFound) { + // If the Crash Monitor could not load a checkpoints file it will + // provide null. This could occur on the first run after updating to + // a version including the Crash Monitor, or if the checkpoints file + // was removed, or on first startup with this profile, or after Firefox Reset. + + // There was no checkpoints file and no sessionstore.js or its backups, + // so we will assume that this was a fresh profile. + this._previousSessionCrashed = false; + } else { + // If this is the first run after an update, sessionstore.js should + // still contain the session.state flag to indicate if the session + // crashed. If it is not present, we will assume this was not the first + // run after update and the checkpoints file was somehow corrupted or + // removed by a crash. + // + // If the session.state flag is present, we will fallback to using it + // for crash detection - If the last write of sessionstore.js had it + // set to "running", we crashed. + let stateFlagPresent = + this._initialState.session && this._initialState.session.state; + + this._previousSessionCrashed = + !stateFlagPresent || + this._initialState.session.state == STATE_RUNNING_STR; + } + + // Report shutdown success via telemetry. Shortcoming here are + // being-killed-by-OS-shutdown-logic, shutdown freezing after + // session restore was written, etc. + Services.telemetry + .getHistogramById("SHUTDOWN_OK") + .add(!this._previousSessionCrashed); + + Services.obs.addObserver(this, "sessionstore-windows-restored", true); + + if (this.sessionType == this.NO_SESSION) { + this._initialState = null; // Reset the state. + } else { + Services.obs.addObserver(this, "browser:purge-session-history", true); + } + + // We're ready. Notify everyone else. + Services.obs.notifyObservers(null, "sessionstore-state-finalized"); + + gOnceInitializedDeferred.resolve(); + }); + }, + + /** + * Handle notifications + */ + observe(subject, topic, data) { + switch (topic) { + case "sessionstore-windows-restored": + Services.obs.removeObserver(this, "sessionstore-windows-restored"); + // Free _initialState after nsSessionStore is done with it. + this._initialState = null; + this._didRestore = true; + break; + case "browser:purge-session-history": + Services.obs.removeObserver(this, "browser:purge-session-history"); + // Reset all state on sanitization. + this._sessionType = this.NO_SESSION; + break; + } + }, + + /* ........ Public API ................*/ + + get onceInitialized() { + return gOnceInitializedDeferred.promise; + }, + + /** + * Get the session state as a jsval + */ + get state() { + return this._initialState; + }, + + /** + * Determines whether automatic session restoration is enabled for this + * launch of the browser. This does not include crash restoration. In + * particular, if session restore is configured to restore only in case of + * crash, this method returns false. + * @returns bool + */ + isAutomaticRestoreEnabled() { + if (this._resumeSessionEnabled === null) { + this._resumeSessionEnabled = + !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing && + (Services.prefs.getBoolPref( + "browser.sessionstore.resume_session_once" + ) || + Services.prefs.getIntPref("browser.startup.page") == + BROWSER_STARTUP_RESUME_SESSION); + } + + return this._resumeSessionEnabled; + }, + + /** + * Determines whether there is a pending session restore. + * @returns bool + */ + willRestore() { + return ( + this.sessionType == this.RECOVER_SESSION || + this.sessionType == this.RESUME_SESSION + ); + }, + + /** + * Determines whether there is a pending session restore and if that will refer + * back to a crash. + * @returns bool + */ + willRestoreAsCrashed() { + return this.sessionType == this.RECOVER_SESSION; + }, + + /** + * Returns a boolean or a promise that resolves to a boolean, indicating + * whether we will restore a session that ends up replacing the homepage. + * True guarantees that we'll restore a session; false means that we + * /probably/ won't do so. + * The browser uses this to avoid unnecessarily loading the homepage when + * restoring a session. + */ + get willOverrideHomepage() { + // If the session file hasn't been read yet and resuming the session isn't + // enabled via prefs, go ahead and load the homepage. We may still replace + // it when recovering from a crash, which we'll only know after reading the + // session file, but waiting for that would delay loading the homepage in + // the non-crash case. + if (!this._initialState && !this.isAutomaticRestoreEnabled()) { + return false; + } + // If we've already restored the session, we won't override again. + if (this._didRestore) { + return false; + } + + return new Promise(resolve => { + this.onceInitialized.then(() => { + // If there are valid windows with not only pinned tabs, signal that we + // will override the default homepage by restoring a session. + resolve( + this.willRestore() && + this._initialState && + this._initialState.windows && + (!this.willRestoreAsCrashed() + ? this._initialState.windows.filter(w => !w._maybeDontRestoreTabs) + : this._initialState.windows + ).some(w => w.tabs.some(t => !t.pinned)) + ); + }); + }); + }, + + /** + * Get the type of pending session store, if any. + */ + get sessionType() { + if (this._sessionType === null) { + let resumeFromCrash = Services.prefs.getBoolPref( + "browser.sessionstore.resume_from_crash" + ); + // Set the startup type. + if (this.isAutomaticRestoreEnabled()) { + this._sessionType = this.RESUME_SESSION; + } else if (this._previousSessionCrashed && resumeFromCrash) { + this._sessionType = this.RECOVER_SESSION; + } else if (this._initialState) { + this._sessionType = this.DEFER_SESSION; + } else { + this._sessionType = this.NO_SESSION; + } + } + + return this._sessionType; + }, + + /** + * Get whether the previous session crashed. + */ + get previousSessionCrashed() { + return this._previousSessionCrashed; + }, + + resetForTest() { + this._resumeSessionEnabled = null; + this._sessionType = null; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), +}; diff --git a/browser/components/sessionstore/SessionStore.sys.mjs b/browser/components/sessionstore/SessionStore.sys.mjs new file mode 100644 index 0000000000..36d2bb4ecd --- /dev/null +++ b/browser/components/sessionstore/SessionStore.sys.mjs @@ -0,0 +1,6861 @@ +/* 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/. */ + +// Current version of the format used by Session Restore. +const FORMAT_VERSION = 1; + +const TAB_CUSTOM_VALUES = new WeakMap(); +const TAB_LAZY_STATES = new WeakMap(); +const TAB_STATE_NEEDS_RESTORE = 1; +const TAB_STATE_RESTORING = 2; +const TAB_STATE_FOR_BROWSER = new WeakMap(); +const WINDOW_RESTORE_IDS = new WeakMap(); +const WINDOW_RESTORE_ZINDICES = new WeakMap(); +const WINDOW_SHOWING_PROMISES = new Map(); +const WINDOW_FLUSHING_PROMISES = new Map(); + +// A new window has just been restored. At this stage, tabs are generally +// not restored. +const NOTIFY_SINGLE_WINDOW_RESTORED = "sessionstore-single-window-restored"; +const NOTIFY_WINDOWS_RESTORED = "sessionstore-windows-restored"; +const NOTIFY_BROWSER_STATE_RESTORED = "sessionstore-browser-state-restored"; +const NOTIFY_LAST_SESSION_CLEARED = "sessionstore-last-session-cleared"; +const NOTIFY_RESTORING_ON_STARTUP = "sessionstore-restoring-on-startup"; +const NOTIFY_INITIATING_MANUAL_RESTORE = + "sessionstore-initiating-manual-restore"; +const NOTIFY_CLOSED_OBJECTS_CHANGED = "sessionstore-closed-objects-changed"; + +const NOTIFY_TAB_RESTORED = "sessionstore-debug-tab-restored"; // WARNING: debug-only +const NOTIFY_DOMWINDOWCLOSED_HANDLED = + "sessionstore-debug-domwindowclosed-handled"; // WARNING: debug-only + +const NOTIFY_BROWSER_SHUTDOWN_FLUSH = "sessionstore-browser-shutdown-flush"; + +// Maximum number of tabs to restore simultaneously. Previously controlled by +// the browser.sessionstore.max_concurrent_tabs pref. +const MAX_CONCURRENT_TAB_RESTORES = 3; + +// Amount (in CSS px) by which we allow window edges to be off-screen +// when restoring a window, before we override the saved position to +// pull the window back within the available screen area. +const SCREEN_EDGE_SLOP = 8; + +// global notifications observed +const OBSERVING = [ + "browser-window-before-show", + "domwindowclosed", + "quit-application-granted", + "browser-lastwindow-close-granted", + "quit-application", + "browser:purge-session-history", + "browser:purge-session-history-for-domain", + "idle-daily", + "clear-origin-attributes-data", + "browsing-context-did-set-embedder", + "browsing-context-discarded", + "browser-shutdown-tabstate-updated", +]; + +// XUL Window properties to (re)store +// Restored in restoreDimensions() +const WINDOW_ATTRIBUTES = ["width", "height", "screenX", "screenY", "sizemode"]; + +const CHROME_FLAGS_MAP = [ + [Ci.nsIWebBrowserChrome.CHROME_TITLEBAR, "titlebar"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_CLOSE, "close"], + [Ci.nsIWebBrowserChrome.CHROME_TOOLBAR, "toolbar"], + [Ci.nsIWebBrowserChrome.CHROME_LOCATIONBAR, "location"], + [Ci.nsIWebBrowserChrome.CHROME_PERSONAL_TOOLBAR, "personalbar"], + [Ci.nsIWebBrowserChrome.CHROME_STATUSBAR, "status"], + [Ci.nsIWebBrowserChrome.CHROME_MENUBAR, "menubar"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_RESIZE, "resizable"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_MIN, "minimizable"], + [Ci.nsIWebBrowserChrome.CHROME_SCROLLBARS, "", "scrollbars=0"], + [Ci.nsIWebBrowserChrome.CHROME_PRIVATE_WINDOW, "private"], + [Ci.nsIWebBrowserChrome.CHROME_NON_PRIVATE_WINDOW, "non-private"], + // Do not inherit remoteness and fissionness from the previous session. + //[Ci.nsIWebBrowserChrome.CHROME_REMOTE_WINDOW, "remote", "non-remote"], + //[Ci.nsIWebBrowserChrome.CHROME_FISSION_WINDOW, "fission", "non-fission"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_POPUP, "popup"], + [ + Ci.nsIWebBrowserChrome.CHROME_WINDOW_POPUP | + Ci.nsIWebBrowserChrome.CHROME_TITLEBAR, + "", + "titlebar=0", + ], + [ + Ci.nsIWebBrowserChrome.CHROME_WINDOW_POPUP | + Ci.nsIWebBrowserChrome.CHROME_WINDOW_CLOSE, + "", + "close=0", + ], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_LOWERED, "alwayslowered"], + [Ci.nsIWebBrowserChrome.CHROME_WINDOW_RAISED, "alwaysraised"], + // "chrome" and "suppressanimation" are always set. + //[Ci.nsIWebBrowserChrome.CHROME_SUPPRESS_ANIMATION, "suppressanimation"], + [Ci.nsIWebBrowserChrome.CHROME_ALWAYS_ON_TOP, "alwaysontop"], + //[Ci.nsIWebBrowserChrome.CHROME_OPENAS_CHROME, "chrome", "chrome=0"], + [Ci.nsIWebBrowserChrome.CHROME_EXTRA, "extrachrome"], + [Ci.nsIWebBrowserChrome.CHROME_CENTER_SCREEN, "centerscreen"], + [Ci.nsIWebBrowserChrome.CHROME_DEPENDENT, "dependent"], + [Ci.nsIWebBrowserChrome.CHROME_MODAL, "modal"], + [Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG, "dialog", "dialog=0"], +]; + +// Hideable window features to (re)store +// Restored in restoreWindowFeatures() +const WINDOW_HIDEABLE_FEATURES = [ + "menubar", + "toolbar", + "locationbar", + "personalbar", + "statusbar", + "scrollbars", +]; + +const WINDOW_OPEN_FEATURES_MAP = { + locationbar: "location", + statusbar: "status", +}; + +// Messages that will be received via the Frame Message Manager. +const MESSAGES = [ + // The content script sends us data that has been invalidated and needs to + // be saved to disk. + "SessionStore:update", + + // The restoreHistory code has run. This is a good time to run SSTabRestoring. + "SessionStore:restoreHistoryComplete", + + // The load for the restoring tab has begun. We update the URL bar at this + // time; if we did it before, the load would overwrite it. + "SessionStore:restoreTabContentStarted", + + // All network loads for a restoring tab are done, so we should + // consider restoring another tab in the queue. The document has + // been restored, and forms have been filled. We trigger + // SSTabRestored at this time. + "SessionStore:restoreTabContentComplete", + + // The content script encountered an error. + "SessionStore:error", +]; + +// The list of messages we accept from <xul:browser>s that have no tab +// assigned, or whose windows have gone away. Those are for example the +// ones that preload about:newtab pages, or from browsers where the window +// has just been closed. +const NOTAB_MESSAGES = new Set([ + // For a description see above. + "SessionStore:update", + + // For a description see above. + "SessionStore:error", +]); + +// The list of messages we accept without an "epoch" parameter. +// See getCurrentEpoch() and friends to find out what an "epoch" is. +const NOEPOCH_MESSAGES = new Set([ + // For a description see above. + "SessionStore:error", +]); + +// The list of messages we want to receive even during the short period after a +// frame has been removed from the DOM and before its frame script has finished +// unloading. +const CLOSED_MESSAGES = new Set([ + // For a description see above. + "SessionStore:update", + + // For a description see above. + "SessionStore:error", +]); + +// These are tab events that we listen to. +const TAB_EVENTS = [ + "TabOpen", + "TabBrowserInserted", + "TabClose", + "TabSelect", + "TabShow", + "TabHide", + "TabPinned", + "TabUnpinned", +]; + +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +/** + * When calling restoreTabContent, we can supply a reason why + * the content is being restored. These are those reasons. + */ +const RESTORE_TAB_CONTENT_REASON = { + /** + * SET_STATE: + * We're restoring this tab's content because we're setting + * state inside this browser tab, probably because the user + * has asked us to restore a tab (or window, or entire session). + */ + SET_STATE: 0, + /** + * NAVIGATE_AND_RESTORE: + * We're restoring this tab's content because a navigation caused + * us to do a remoteness-flip. + */ + NAVIGATE_AND_RESTORE: 1, +}; + +// 'browser.startup.page' preference value to resume the previous session. +const BROWSER_STARTUP_RESUME_SESSION = 3; + +// Used by SessionHistoryListener. +const kNoIndex = Number.MAX_SAFE_INTEGER; +const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + +import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; + +import { TelemetryTimestamps } from "resource://gre/modules/TelemetryTimestamps.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { GlobalState } from "resource:///modules/sessionstore/GlobalState.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetters(lazy, { + gScreenManager: ["@mozilla.org/gfx/screenmanager;1", "nsIScreenManager"], +}); + +ChromeUtils.defineESModuleGetters(lazy, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + RunState: "resource:///modules/sessionstore/RunState.sys.mjs", + SessionCookies: "resource:///modules/sessionstore/SessionCookies.sys.mjs", + SessionFile: "resource:///modules/sessionstore/SessionFile.sys.mjs", + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", + SessionSaver: "resource:///modules/sessionstore/SessionSaver.sys.mjs", + SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs", + TabAttributes: "resource:///modules/sessionstore/TabAttributes.sys.mjs", + TabState: "resource:///modules/sessionstore/TabState.sys.mjs", + TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm", + HomePage: "resource:///modules/HomePage.jsm", + TabCrashHandler: "resource:///modules/ContentCrashHandlers.jsm", +}); + +/** + * |true| if we are in debug mode, |false| otherwise. + * Debug mode is controlled by preference browser.sessionstore.debug + */ +var gDebuggingEnabled = false; + +/** + * A global value to tell that fingerprinting resistance is enabled or not. + * If it's enabled, the session restore won't restore the window's size and + * size mode. + * This value is controlled by preference privacy.resistFingerprinting. + */ +var gResistFingerprintingEnabled = false; + +export var SessionStore = { + get promiseInitialized() { + return SessionStoreInternal.promiseInitialized; + }, + + get promiseAllWindowsRestored() { + return SessionStoreInternal.promiseAllWindowsRestored; + }, + + get canRestoreLastSession() { + return SessionStoreInternal.canRestoreLastSession; + }, + + set canRestoreLastSession(val) { + SessionStoreInternal.canRestoreLastSession = val; + }, + + get lastClosedObjectType() { + return SessionStoreInternal.lastClosedObjectType; + }, + + get willAutoRestore() { + return SessionStoreInternal.willAutoRestore; + }, + + init: function ss_init() { + SessionStoreInternal.init(); + }, + + getBrowserState: function ss_getBrowserState() { + return SessionStoreInternal.getBrowserState(); + }, + + setBrowserState: function ss_setBrowserState(aState) { + SessionStoreInternal.setBrowserState(aState); + }, + + getWindowState: function ss_getWindowState(aWindow) { + return SessionStoreInternal.getWindowState(aWindow); + }, + + setWindowState: function ss_setWindowState(aWindow, aState, aOverwrite) { + SessionStoreInternal.setWindowState(aWindow, aState, aOverwrite); + }, + + getTabState: function ss_getTabState(aTab) { + return SessionStoreInternal.getTabState(aTab); + }, + + setTabState: function ss_setTabState(aTab, aState) { + SessionStoreInternal.setTabState(aTab, aState); + }, + + // Return whether a tab is restoring. + isTabRestoring(aTab) { + return TAB_STATE_FOR_BROWSER.has(aTab.linkedBrowser); + }, + + getInternalObjectState(obj) { + return SessionStoreInternal.getInternalObjectState(obj); + }, + + duplicateTab: function ss_duplicateTab( + aWindow, + aTab, + aDelta = 0, + aRestoreImmediately = true, + aOptions = {} + ) { + return SessionStoreInternal.duplicateTab( + aWindow, + aTab, + aDelta, + aRestoreImmediately, + aOptions + ); + }, + + getLastClosedTabCount(aWindow) { + return SessionStoreInternal.getLastClosedTabCount(aWindow); + }, + + resetLastClosedTabCount(aWindow) { + SessionStoreInternal.resetLastClosedTabCount(aWindow); + }, + + getClosedTabCount: function ss_getClosedTabCount(aWindow) { + return SessionStoreInternal.getClosedTabCount(aWindow); + }, + + getClosedTabData: function ss_getClosedTabData(aWindow) { + return SessionStoreInternal.getClosedTabData(aWindow); + }, + + undoCloseTab: function ss_undoCloseTab(aWindow, aIndex) { + return SessionStoreInternal.undoCloseTab(aWindow, aIndex); + }, + + forgetClosedTab: function ss_forgetClosedTab(aWindow, aIndex) { + return SessionStoreInternal.forgetClosedTab(aWindow, aIndex); + }, + + getClosedWindowCount: function ss_getClosedWindowCount() { + return SessionStoreInternal.getClosedWindowCount(); + }, + + getClosedWindowData: function ss_getClosedWindowData() { + return SessionStoreInternal.getClosedWindowData(); + }, + + maybeDontRestoreTabs(aWindow) { + SessionStoreInternal.maybeDontRestoreTabs(aWindow); + }, + + undoCloseWindow: function ss_undoCloseWindow(aIndex) { + return SessionStoreInternal.undoCloseWindow(aIndex); + }, + + forgetClosedWindow: function ss_forgetClosedWindow(aIndex) { + return SessionStoreInternal.forgetClosedWindow(aIndex); + }, + + getCustomWindowValue(aWindow, aKey) { + return SessionStoreInternal.getCustomWindowValue(aWindow, aKey); + }, + + setCustomWindowValue(aWindow, aKey, aStringValue) { + SessionStoreInternal.setCustomWindowValue(aWindow, aKey, aStringValue); + }, + + deleteCustomWindowValue(aWindow, aKey) { + SessionStoreInternal.deleteCustomWindowValue(aWindow, aKey); + }, + + getCustomTabValue(aTab, aKey) { + return SessionStoreInternal.getCustomTabValue(aTab, aKey); + }, + + setCustomTabValue(aTab, aKey, aStringValue) { + SessionStoreInternal.setCustomTabValue(aTab, aKey, aStringValue); + }, + + deleteCustomTabValue(aTab, aKey) { + SessionStoreInternal.deleteCustomTabValue(aTab, aKey); + }, + + getLazyTabValue(aTab, aKey) { + return SessionStoreInternal.getLazyTabValue(aTab, aKey); + }, + + getCustomGlobalValue(aKey) { + return SessionStoreInternal.getCustomGlobalValue(aKey); + }, + + setCustomGlobalValue(aKey, aStringValue) { + SessionStoreInternal.setCustomGlobalValue(aKey, aStringValue); + }, + + deleteCustomGlobalValue(aKey) { + SessionStoreInternal.deleteCustomGlobalValue(aKey); + }, + + persistTabAttribute: function ss_persistTabAttribute(aName) { + SessionStoreInternal.persistTabAttribute(aName); + }, + + restoreLastSession: function ss_restoreLastSession() { + SessionStoreInternal.restoreLastSession(); + }, + + speculativeConnectOnTabHover(tab) { + SessionStoreInternal.speculativeConnectOnTabHover(tab); + }, + + getCurrentState(aUpdateAll) { + return SessionStoreInternal.getCurrentState(aUpdateAll); + }, + + reviveCrashedTab(aTab) { + return SessionStoreInternal.reviveCrashedTab(aTab); + }, + + reviveAllCrashedTabs() { + return SessionStoreInternal.reviveAllCrashedTabs(); + }, + + updateSessionStoreFromTablistener( + aBrowser, + aBrowsingContext, + aPermanentKey, + aData + ) { + return SessionStoreInternal.updateSessionStoreFromTablistener( + aBrowser, + aBrowsingContext, + aPermanentKey, + aData + ); + }, + + getSessionHistory(tab, updatedCallback) { + return SessionStoreInternal.getSessionHistory(tab, updatedCallback); + }, + + undoCloseById(aClosedId, aIncludePrivate) { + return SessionStoreInternal.undoCloseById(aClosedId, aIncludePrivate); + }, + + resetBrowserToLazyState(tab) { + return SessionStoreInternal.resetBrowserToLazyState(tab); + }, + + maybeExitCrashedState(browser) { + SessionStoreInternal.maybeExitCrashedState(browser); + }, + + isBrowserInCrashedSet(browser) { + return SessionStoreInternal.isBrowserInCrashedSet(browser); + }, + + // this is used for testing purposes + resetNextClosedId() { + SessionStoreInternal._nextClosedId = 0; + }, + + /** + * Ensures that session store has registered and started tracking a given window. + * @param window + * Window reference + */ + ensureInitialized(window) { + if (SessionStoreInternal._sessionInitialized && !window.__SSi) { + /* + We need to check that __SSi is not defined on the window so that if + onLoad function is in the middle of executing we don't enter the function + again and try to redeclare the ContentSessionStore script. + */ + SessionStoreInternal.onLoad(window); + } + }, + + getCurrentEpoch(browser) { + return SessionStoreInternal.getCurrentEpoch(browser.permanentKey); + }, + + /** + * Determines whether the passed version number is compatible with + * the current version number of the SessionStore. + * + * @param version The format and version of the file, as an array, e.g. + * ["sessionrestore", 1] + */ + isFormatVersionCompatible(version) { + if (!version) { + return false; + } + if (!Array.isArray(version)) { + // Improper format. + return false; + } + if (version[0] != "sessionrestore") { + // Not a Session Restore file. + return false; + } + let number = Number.parseFloat(version[1]); + if (Number.isNaN(number)) { + return false; + } + return number <= FORMAT_VERSION; + }, + + /** + * Filters out not worth-saving tabs from a given browser state object. + * + * @param aState (object) + * The browser state for which we remove worth-saving tabs. + * The given object will be modified. + */ + keepOnlyWorthSavingTabs(aState) { + let closedWindowShouldRestore = null; + for (let i = aState.windows.length - 1; i >= 0; i--) { + let win = aState.windows[i]; + for (let j = win.tabs.length - 1; j >= 0; j--) { + let tab = win.tabs[j]; + if (!SessionStoreInternal._shouldSaveTab(tab)) { + win.tabs.splice(j, 1); + if (win.selected > j) { + win.selected--; + } + } + } + + // If it's the last window (and no closedWindow that will restore), keep the window state with no tabs. + if ( + !win.tabs.length && + (aState.windows.length > 1 || + closedWindowShouldRestore || + (closedWindowShouldRestore == null && + (closedWindowShouldRestore = aState._closedWindows.some( + w => w._shouldRestore + )))) + ) { + aState.windows.splice(i, 1); + if (aState.selectedWindow > i) { + aState.selectedWindow--; + } + } + } + }, + + /** + * Prepares to change the remoteness of the given browser, by ensuring that + * the local instance of session history is up-to-date. + */ + async prepareToChangeRemoteness(aTab) { + await SessionStoreInternal.prepareToChangeRemoteness(aTab); + }, + + finishTabRemotenessChange(aTab, aSwitchId) { + SessionStoreInternal.finishTabRemotenessChange(aTab, aSwitchId); + }, +}; + +// Freeze the SessionStore object. We don't want anyone to modify it. +Object.freeze(SessionStore); + +var SessionStoreInternal = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + _globalState: new GlobalState(), + + // A counter to be used to generate a unique ID for each closed tab or window. + _nextClosedId: 0, + + // During the initial restore and setBrowserState calls tracks the number of + // windows yet to be restored + _restoreCount: -1, + + // For each <browser> element, records the SHistoryListener. + _browserSHistoryListener: new WeakMap(), + + // Tracks the various listeners that are used throughout the restore. + _restoreListeners: new WeakMap(), + + // Records the promise created in _restoreHistory, which is used to track + // the completion of the first phase of the restore. + _tabStateRestorePromises: new WeakMap(), + + // The history data needed to be restored in the parent. + _tabStateToRestore: new WeakMap(), + + // For each <browser> element, records the current epoch. + _browserEpochs: new WeakMap(), + + // Any browsers that fires the oop-browser-crashed event gets stored in + // here - that way we know which browsers to ignore messages from (until + // they get restored). + _crashedBrowsers: new WeakSet(), + + // A map (xul:browser -> FrameLoader) that maps a browser to the last + // associated frameLoader we heard about. + _lastKnownFrameLoader: new WeakMap(), + + // A map (xul:browser -> object) that maps a browser associated with a + // recently closed tab to all its necessary state information we need to + // properly handle final update message. + _closedTabs: new WeakMap(), + + // A map (xul:browser -> object) that maps a browser associated with a + // recently closed tab due to a window closure to the tab state information + // that is being stored in _closedWindows for that tab. + _closedWindowTabs: new WeakMap(), + + // A set of window data that has the potential to be saved in the _closedWindows + // array for the session. We will remove window data from this set whenever + // forgetClosedWindow is called for the window, or when session history is + // purged, so that we don't accidentally save that data after the flush has + // completed. Closed tabs use a more complicated mechanism for this particular + // problem. When forgetClosedTab is called, the browser is removed from the + // _closedTabs map, so its data is not recorded. In the purge history case, + // the closedTabs array per window is overwritten so that once the flush is + // complete, the tab would only ever add itself to an array that SessionStore + // no longer cares about. Bug 1230636 has been filed to make the tab case + // work more like the window case, which is more explicit, and easier to + // reason about. + _saveableClosedWindowData: new WeakSet(), + + // whether a setBrowserState call is in progress + _browserSetState: false, + + // time in milliseconds when the session was started (saved across sessions), + // defaults to now if no session was restored or timestamp doesn't exist + _sessionStartTime: Date.now(), + + // states for all currently opened windows + _windows: {}, + + // counter for creating unique window IDs + _nextWindowID: 0, + + // states for all recently closed windows + _closedWindows: [], + + // collection of session states yet to be restored + _statesToRestore: {}, + + // counts the number of crashes since the last clean start + _recentCrashes: 0, + + // whether the last window was closed and should be restored + _restoreLastWindow: false, + + // number of tabs currently restoring + _tabsRestoringCount: 0, + + _log: null, + + // When starting Firefox with a single private window, this is the place + // where we keep the session we actually wanted to restore in case the user + // decides to later open a non-private window as well. + _deferredInitialState: null, + + // Keeps track of whether a notification needs to be sent that closed objects have changed. + _closedObjectsChanged: false, + + // A promise resolved once initialization is complete + _deferredInitialized: (function() { + let deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + return deferred; + })(), + + // Whether session has been initialized + _sessionInitialized: false, + + // A promise resolved once all windows are restored. + _deferredAllWindowsRestored: (function() { + let deferred = {}; + + deferred.promise = new Promise((resolve, reject) => { + deferred.resolve = resolve; + deferred.reject = reject; + }); + + return deferred; + })(), + + get promiseAllWindowsRestored() { + return this._deferredAllWindowsRestored.promise; + }, + + // Promise that is resolved when we're ready to initialize + // and restore the session. + _promiseReadyForInitialization: null, + + // Keep busy state counters per window. + _windowBusyStates: new WeakMap(), + + /** + * A promise fulfilled once initialization is complete. + */ + get promiseInitialized() { + return this._deferredInitialized.promise; + }, + + get canRestoreLastSession() { + return LastSession.canRestore; + }, + + set canRestoreLastSession(val) { + // Cheat a bit; only allow false. + if (!val) { + LastSession.clear(); + } + }, + + /** + * Returns a string describing the last closed object, either "tab" or "window". + * + * This was added to support the sessions.restore WebExtensions API. + */ + get lastClosedObjectType() { + if (this._closedWindows.length) { + // Since there are closed windows, we need to check if there's a closed tab + // in one of the currently open windows that was closed after the + // last-closed window. + let tabTimestamps = []; + for (let window of Services.wm.getEnumerator("navigator:browser")) { + let windowState = this._windows[window.__SSi]; + if (windowState && windowState._closedTabs[0]) { + tabTimestamps.push(windowState._closedTabs[0].closedAt); + } + } + if ( + !tabTimestamps.length || + tabTimestamps.sort((a, b) => b - a)[0] < this._closedWindows[0].closedAt + ) { + return "window"; + } + } + return "tab"; + }, + + /** + * Returns a boolean that determines whether the session will be automatically + * restored upon the _next_ startup or a restart. + */ + get willAutoRestore() { + return ( + !PrivateBrowsingUtils.permanentPrivateBrowsing && + (Services.prefs.getBoolPref("browser.sessionstore.resume_session_once") || + Services.prefs.getIntPref("browser.startup.page") == + BROWSER_STARTUP_RESUME_SESSION) + ); + }, + + /** + * Initialize the sessionstore service. + */ + init() { + if (this._initialized) { + throw new Error("SessionStore.init() must only be called once!"); + } + + TelemetryTimestamps.add("sessionRestoreInitialized"); + OBSERVING.forEach(function(aTopic) { + Services.obs.addObserver(this, aTopic, true); + }, this); + + this._initPrefs(); + this._initialized = true; + + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_PRIVACY_LEVEL") + .add(Services.prefs.getIntPref("browser.sessionstore.privacy_level")); + }, + + /** + * Initialize the session using the state provided by SessionStartup + */ + initSession() { + TelemetryStopwatch.start("FX_SESSION_RESTORE_STARTUP_INIT_SESSION_MS"); + let state; + let ss = lazy.SessionStartup; + + if (ss.willRestore() || ss.sessionType == ss.DEFER_SESSION) { + state = ss.state; + } + + if (state) { + try { + // If we're doing a DEFERRED session, then we want to pull pinned tabs + // out so they can be restored. + if (ss.sessionType == ss.DEFER_SESSION) { + let [iniState, remainingState] = this._prepDataForDeferredRestore( + state + ); + // If we have a iniState with windows, that means that we have windows + // with app tabs to restore. + if (iniState.windows.length) { + // Move cookies over from the remaining state so that they're + // restored right away, and pinned tabs will load correctly. + iniState.cookies = remainingState.cookies; + delete remainingState.cookies; + state = iniState; + } else { + state = null; + } + + if (remainingState.windows.length) { + LastSession.setState(remainingState); + } + Services.telemetry.keyedScalarAdd( + "browser.engagement.sessionrestore_interstitial", + "deferred_restore", + 1 + ); + } else { + // Get the last deferred session in case the user still wants to + // restore it + LastSession.setState(state.lastSessionState); + + let restoreAsCrashed = ss.willRestoreAsCrashed(); + if (restoreAsCrashed) { + this._recentCrashes = + ((state.session && state.session.recentCrashes) || 0) + 1; + + // _needsRestorePage will record sessionrestore_interstitial, + // including the specific reason we decided we needed to show + // about:sessionrestore, if that's what we do. + if (this._needsRestorePage(state, this._recentCrashes)) { + // replace the crashed session with a restore-page-only session + let url = "about:sessionrestore"; + let formdata = { id: { sessionData: state }, url }; + let entry = { + url, + triggeringPrincipal_base64: + lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL, + }; + state = { windows: [{ tabs: [{ entries: [entry], formdata }] }] }; + } else if ( + this._hasSingleTabWithURL(state.windows, "about:welcomeback") + ) { + Services.telemetry.keyedScalarAdd( + "browser.engagement.sessionrestore_interstitial", + "shown_only_about_welcomeback", + 1 + ); + // On a single about:welcomeback URL that crashed, replace about:welcomeback + // with about:sessionrestore, to make clear to the user that we crashed. + state.windows[0].tabs[0].entries[0].url = "about:sessionrestore"; + state.windows[0].tabs[0].entries[0].triggeringPrincipal_base64 = + lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + } else { + restoreAsCrashed = false; + } + } + + // If we didn't use about:sessionrestore, record that: + if (!restoreAsCrashed) { + Services.telemetry.keyedScalarAdd( + "browser.engagement.sessionrestore_interstitial", + "autorestore", + 1 + ); + + this._removeExplicitlyClosedTabs(state); + } + + // Update the session start time using the restored session state. + this._updateSessionStartTime(state); + + // Make sure that at least the first window doesn't have anything hidden. + delete state.windows[0].hidden; + // Since nothing is hidden in the first window, it cannot be a popup. + delete state.windows[0].isPopup; + // We don't want to minimize and then open a window at startup. + if (state.windows[0].sizemode == "minimized") { + state.windows[0].sizemode = "normal"; + } + + // clear any lastSessionWindowID attributes since those don't matter + // during normal restore + state.windows.forEach(function(aWindow) { + delete aWindow.__lastSessionWindowID; + }); + } + + // clear _maybeDontRestoreTabs because we have restored (or not) + // windows and so they don't matter + state?.windows?.forEach(win => delete win._maybeDontRestoreTabs); + state?._closedWindows?.forEach(win => delete win._maybeDontRestoreTabs); + } catch (ex) { + this._log.error("The session file is invalid: " + ex); + } + } + + // at this point, we've as good as resumed the session, so we can + // clear the resume_session_once flag, if it's set + if ( + !lazy.RunState.isQuitting && + this._prefBranch.getBoolPref("sessionstore.resume_session_once") + ) { + this._prefBranch.setBoolPref("sessionstore.resume_session_once", false); + } + + TelemetryStopwatch.finish("FX_SESSION_RESTORE_STARTUP_INIT_SESSION_MS"); + return state; + }, + + /** + * When initializing session, if we are restoring the last session at startup, + * close open tabs or close windows marked _maybeDontRestoreTabs (if they were closed + * by closing remaining tabs). + * See bug 490136 + */ + _removeExplicitlyClosedTabs(state) { + // Don't restore tabs that has been explicitly closed + for (let i = 0; i < state.windows.length; ) { + const winData = state.windows[i]; + if (winData._maybeDontRestoreTabs) { + if (state.windows.length == 1) { + // it's the last window, we just want to close tabs + let j = 0; + // reset close group (we don't want to append tabs to existing group close). + winData._lastClosedTabGroupCount = -1; + while (winData.tabs.length) { + const tabState = winData.tabs.pop(); + + // Ensure the index is in bounds. + let activeIndex = (tabState.index || tabState.entries.length) - 1; + activeIndex = Math.min(activeIndex, tabState.entries.length - 1); + activeIndex = Math.max(activeIndex, 0); + + let title = ""; + if (activeIndex in tabState.entries) { + title = + tabState.entries[activeIndex].title || + tabState.entries[activeIndex].url; + } + + const tabData = { + state: tabState, + title, + image: tabState.image, + pos: j++, + closedAt: Date.now(), + closedInGroup: true, + }; + if (this._shouldSaveTabState(tabState)) { + this.saveClosedTabData(winData, winData._closedTabs, tabData); + } + } + } else { + // We can remove the window since it doesn't have any + // tabs that we should restore and it's not the only window + if (winData.tabs.some(this._shouldSaveTabState)) { + winData.closedAt = Date.now(); + state._closedWindows.unshift(winData); + } + state.windows.splice(i, 1); + continue; // we don't want to increment the index + } + } + i++; + } + }, + + _initPrefs() { + this._prefBranch = Services.prefs.getBranch("browser."); + + gDebuggingEnabled = this._prefBranch.getBoolPref("sessionstore.debug"); + + Services.prefs.addObserver("browser.sessionstore.debug", () => { + gDebuggingEnabled = this._prefBranch.getBoolPref("sessionstore.debug"); + }); + + this._log = console.createInstance({ + prefix: "SessionStore", + maxLogLevel: gDebuggingEnabled ? "Debug" : "Warn", + }); + + this._max_tabs_undo = this._prefBranch.getIntPref( + "sessionstore.max_tabs_undo" + ); + this._prefBranch.addObserver("sessionstore.max_tabs_undo", this, true); + + this._max_windows_undo = this._prefBranch.getIntPref( + "sessionstore.max_windows_undo" + ); + this._prefBranch.addObserver("sessionstore.max_windows_undo", this, true); + + this._restore_on_demand = this._prefBranch.getBoolPref( + "sessionstore.restore_on_demand" + ); + this._prefBranch.addObserver("sessionstore.restore_on_demand", this, true); + + gResistFingerprintingEnabled = Services.prefs.getBoolPref( + "privacy.resistFingerprinting" + ); + Services.prefs.addObserver("privacy.resistFingerprinting", this); + + this._shistoryInParent = Services.appinfo.sessionHistoryInParent; + }, + + /** + * Called on application shutdown, after notifications: + * quit-application-granted, quit-application + */ + _uninit: function ssi_uninit() { + if (!this._initialized) { + throw new Error("SessionStore is not initialized."); + } + + // Prepare to close the session file and write the last state. + lazy.RunState.setClosing(); + + // save all data for session resuming + if (this._sessionInitialized) { + lazy.SessionSaver.run(); + } + + // clear out priority queue in case it's still holding refs + TabRestoreQueue.reset(); + + // Make sure to cancel pending saves. + lazy.SessionSaver.cancel(); + }, + + /** + * Handle notifications + */ + observe: function ssi_observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "browser-window-before-show": // catch new windows + this.onBeforeBrowserWindowShown(aSubject); + break; + case "domwindowclosed": // catch closed windows + this.onClose(aSubject).then(() => { + this._notifyOfClosedObjectsChange(); + }); + if (gDebuggingEnabled) { + Services.obs.notifyObservers(null, NOTIFY_DOMWINDOWCLOSED_HANDLED); + } + break; + case "quit-application-granted": + let syncShutdown = aData == "syncShutdown"; + this.onQuitApplicationGranted(syncShutdown); + break; + case "browser-lastwindow-close-granted": + this.onLastWindowCloseGranted(); + break; + case "quit-application": + this.onQuitApplication(aData); + break; + case "browser:purge-session-history": // catch sanitization + this.onPurgeSessionHistory(); + this._notifyOfClosedObjectsChange(); + break; + case "browser:purge-session-history-for-domain": + this.onPurgeDomainData(aData); + this._notifyOfClosedObjectsChange(); + break; + case "nsPref:changed": // catch pref changes + this.onPrefChange(aData); + this._notifyOfClosedObjectsChange(); + break; + case "idle-daily": + this.onIdleDaily(); + this._notifyOfClosedObjectsChange(); + break; + case "clear-origin-attributes-data": + let userContextId = 0; + try { + userContextId = JSON.parse(aData).userContextId; + } catch (e) {} + if (userContextId) { + this._forgetTabsWithUserContextId(userContextId); + } + break; + case "browsing-context-did-set-embedder": + if (Services.appinfo.sessionHistoryInParent) { + if ( + aSubject && + aSubject === aSubject.top && + aSubject.isContent && + aSubject.embedderElement && + aSubject.embedderElement.permanentKey + ) { + let permanentKey = aSubject.embedderElement.permanentKey; + this._browserSHistoryListener.get(permanentKey)?.unregister(); + this.getOrCreateSHistoryListener(permanentKey, aSubject, true); + } + } + break; + case "browsing-context-discarded": + if (Services.appinfo.sessionHistoryInParent) { + let permanentKey = aSubject?.embedderElement?.permanentKey; + if (permanentKey) { + this._browserSHistoryListener.get(permanentKey)?.unregister(); + } + } + break; + case "browser-shutdown-tabstate-updated": + if (Services.appinfo.sessionHistoryInParent) { + // Non-SHIP code calls this when the frame script is unloaded. + this.onFinalTabStateUpdateComplete(aSubject); + } + this._notifyOfClosedObjectsChange(); + break; + } + }, + + getOrCreateSHistoryListener( + permanentKey, + browsingContext, + collectImmediately = false + ) { + class SHistoryListener { + constructor() { + this.QueryInterface = ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]); + + this._browserId = browsingContext.browserId; + this._fromIndex = kNoIndex; + } + + unregister() { + let bc = BrowsingContext.getCurrentTopByBrowserId(this._browserId); + bc?.sessionHistory?.removeSHistoryListener(this); + SessionStoreInternal._browserSHistoryListener.delete(permanentKey); + } + + collect( + permanentKey, // eslint-disable-line no-shadow + browsingContext, // eslint-disable-line no-shadow + { collectFull = true, writeToCache = false } + ) { + // Don't bother doing anything if we haven't seen any navigations. + if (!collectFull && this._fromIndex === kNoIndex) { + return null; + } + + TelemetryStopwatch.start( + "FX_SESSION_RESTORE_COLLECT_SESSION_HISTORY_MS" + ); + + let fromIndex = collectFull ? -1 : this._fromIndex; + this._fromIndex = kNoIndex; + + let historychange = lazy.SessionHistory.collectFromParent( + browsingContext.currentURI?.spec, + true, // Bug 1704574 + browsingContext.sessionHistory, + fromIndex + ); + + if (writeToCache) { + let win = + browsingContext.embedderElement?.ownerGlobal || + browsingContext.currentWindowGlobal?.browsingContext?.window; + + SessionStoreInternal.onTabStateUpdate(permanentKey, win, { + data: { historychange }, + }); + } + + TelemetryStopwatch.finish( + "FX_SESSION_RESTORE_COLLECT_SESSION_HISTORY_MS" + ); + + return historychange; + } + + collectFrom(index) { + if (this._fromIndex <= index) { + // If we already know that we need to update history from index N we + // can ignore any changes that happened with an element with index + // larger than N. + // + // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which + // means we don't ignore anything here, and in case of navigation in + // the history back and forth cases we use kLastIndex which ignores + // only the subsequent navigations, but not any new elements added. + return; + } + + let bc = BrowsingContext.getCurrentTopByBrowserId(this._browserId); + if (bc?.embedderElement?.frameLoader) { + this._fromIndex = index; + + // Queue a tab state update on the |browser.sessionstore.interval| + // timer. We'll call this.collect() when we receive the update. + bc.embedderElement.frameLoader.requestSHistoryUpdate(); + } + } + + OnHistoryNewEntry(newURI, oldIndex) { + // We use oldIndex - 1 to collect the current entry as well. This makes + // sure to collect any changes that were made to the entry while the + // document was active. + this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1); + } + OnHistoryGotoIndex() { + this.collectFrom(kLastIndex); + } + OnHistoryPurge() { + this.collectFrom(-1); + } + OnHistoryReload() { + this.collectFrom(-1); + return true; + } + OnHistoryReplaceEntry() { + this.collectFrom(-1); + } + } + + if (!Services.appinfo.sessionHistoryInParent) { + throw new Error("This function should only be used with SHIP"); + } + + if (!permanentKey || browsingContext !== browsingContext.top) { + return null; + } + + let sessionHistory = browsingContext.sessionHistory; + if (!sessionHistory) { + return null; + } + + let listener = this._browserSHistoryListener.get(permanentKey); + if (listener) { + return listener; + } + + listener = new SHistoryListener(); + sessionHistory.addSHistoryListener(listener); + this._browserSHistoryListener.set(permanentKey, listener); + + let isAboutBlank = browsingContext.currentURI?.spec === "about:blank"; + + if (collectImmediately && (!isAboutBlank || sessionHistory.count !== 0)) { + listener.collect(permanentKey, browsingContext, { writeToCache: true }); + } + + return listener; + }, + + onTabStateUpdate(permanentKey, win, update) { + // Ignore messages from <browser> elements that have crashed + // and not yet been revived. + if (this._crashedBrowsers.has(permanentKey)) { + return; + } + + lazy.TabState.update(permanentKey, update); + this.saveStateDelayed(win); + + // Handle any updates sent by the child after the tab was closed. This + // might be the final update as sent by the "unload" handler but also + // any async update message that was sent before the child unloaded. + let closedTab = this._closedTabs.get(permanentKey); + if (closedTab) { + // Update the closed tab's state. This will be reflected in its + // window's list of closed tabs as that refers to the same object. + lazy.TabState.copyFromCache(permanentKey, closedTab.tabData.state); + } + }, + + onFinalTabStateUpdateComplete(browser) { + let permanentKey = browser.permanentKey; + if ( + this._closedTabs.has(permanentKey) && + !this._crashedBrowsers.has(permanentKey) + ) { + let { winData, closedTabs, tabData } = this._closedTabs.get(permanentKey); + + // We expect no further updates. + this._closedTabs.delete(permanentKey); + + // The tab state no longer needs this reference. + delete tabData.permanentKey; + + // Determine whether the tab state is worth saving. + let shouldSave = this._shouldSaveTabState(tabData.state); + let index = closedTabs.indexOf(tabData); + + if (shouldSave && index == -1) { + // If the tab state is worth saving and we didn't push it onto + // the list of closed tabs when it was closed (because we deemed + // the state not worth saving) then add it to the window's list + // of closed tabs now. + this.saveClosedTabData(winData, closedTabs, tabData); + } else if (!shouldSave && index > -1) { + // Remove from the list of closed tabs. The update messages sent + // after the tab was closed changed enough state so that we no + // longer consider its data interesting enough to keep around. + this.removeClosedTabData(winData, closedTabs, index); + } + } + + // If this the final message we need to resolve all pending flush + // requests for the given browser as they might have been sent too + // late and will never respond. If they have been sent shortly after + // switching a browser's remoteness there isn't too much data to skip. + lazy.TabStateFlusher.resolveAll(browser); + + this._browserSHistoryListener.get(permanentKey)?.unregister(); + this._restoreListeners.get(permanentKey)?.unregister(); + + Services.obs.notifyObservers(browser, NOTIFY_BROWSER_SHUTDOWN_FLUSH); + }, + + updateSessionStoreFromTablistener( + browser, + browsingContext, + permanentKey, + update + ) { + permanentKey = browser?.permanentKey ?? permanentKey; + if (!permanentKey) { + return; + } + + // Ignore sessionStore update from previous epochs + if (!this.isCurrentEpoch(permanentKey, update.epoch)) { + return; + } + + if (browsingContext.isReplaced) { + return; + } + + if (Services.appinfo.sessionHistoryInParent) { + let listener = this.getOrCreateSHistoryListener( + permanentKey, + browsingContext + ); + + if (listener) { + let historychange = listener.collect(permanentKey, browsingContext, { + collectFull: !!update.sHistoryNeeded, + writeToCache: false, + }); + + if (historychange) { + update.data.historychange = historychange; + } + } + } + + let win = + browser?.ownerGlobal ?? + browsingContext.currentWindowGlobal?.browsingContext?.window; + + this.onTabStateUpdate(permanentKey, win, update); + }, + + /** + * This method handles incoming messages sent by the session store content + * script via the Frame Message Manager or Parent Process Message Manager, + * and thus enables communication with OOP tabs. + */ + receiveMessage(aMessage) { + // If we got here, that means we're dealing with a frame message + // manager message, so the target will be a <xul:browser>. + var browser = aMessage.target; + let win = browser.ownerGlobal; + let tab = win ? win.gBrowser.getTabForBrowser(browser) : null; + + // Ensure we receive only specific messages from <xul:browser>s that + // have no tab or window assigned, e.g. the ones that preload + // about:newtab pages, or windows that have closed. + if (!tab && !NOTAB_MESSAGES.has(aMessage.name)) { + throw new Error( + `received unexpected message '${aMessage.name}' ` + + `from a browser that has no tab or window` + ); + } + + let data = aMessage.data || {}; + let hasEpoch = data.hasOwnProperty("epoch"); + + // Most messages sent by frame scripts require to pass an epoch. + if (!hasEpoch && !NOEPOCH_MESSAGES.has(aMessage.name)) { + throw new Error(`received message '${aMessage.name}' without an epoch`); + } + + // Ignore messages from previous epochs. + if (hasEpoch && !this.isCurrentEpoch(browser.permanentKey, data.epoch)) { + return; + } + + switch (aMessage.name) { + case "SessionStore:update": + // |browser.frameLoader| might be empty if the browser was already + // destroyed and its tab removed. In that case we still have the last + // frameLoader we know about to compare. + let frameLoader = + browser.frameLoader || + this._lastKnownFrameLoader.get(browser.permanentKey); + + // If the message isn't targeting the latest frameLoader discard it. + if (frameLoader != aMessage.targetFrameLoader) { + return; + } + + this.onTabStateUpdate(browser.permanentKey, browser.ownerGlobal, data); + + // SHIP code will call this when it receives "browser-shutdown-tabstate-updated" + if (data.isFinal) { + if (!Services.appinfo.sessionHistoryInParent) { + this.onFinalTabStateUpdateComplete(browser); + } + } else if (data.flushID) { + // This is an update kicked off by an async flush request. Notify the + // TabStateFlusher so that it can finish the request and notify its + // consumer that's waiting for the flush to be done. + lazy.TabStateFlusher.resolve(browser, data.flushID); + } + + break; + case "SessionStore:restoreHistoryComplete": + this._restoreHistoryComplete(browser, data); + break; + case "SessionStore:restoreTabContentStarted": + this._restoreTabContentStarted(browser, data); + break; + case "SessionStore:restoreTabContentComplete": + this._restoreTabContentComplete(browser, data); + break; + case "SessionStore:error": + lazy.TabStateFlusher.resolveAll( + browser, + false, + "Received error from the content process" + ); + break; + default: + throw new Error(`received unknown message '${aMessage.name}'`); + } + }, + + /* ........ Window Event Handlers .............. */ + + /** + * Implement EventListener for handling various window and tab events + */ + handleEvent: function ssi_handleEvent(aEvent) { + let win = aEvent.currentTarget.ownerGlobal; + let target = aEvent.originalTarget; + switch (aEvent.type) { + case "TabOpen": + this.onTabAdd(win); + break; + case "TabBrowserInserted": + this.onTabBrowserInserted(win, target); + break; + case "TabClose": + // `adoptedBy` will be set if the tab was closed because it is being + // moved to a new window. + if (aEvent.detail.adoptedBy) { + this.onMoveToNewWindow( + target.linkedBrowser, + aEvent.detail.adoptedBy.linkedBrowser + ); + } else { + this.onTabClose(win, target); + } + this.onTabRemove(win, target); + this._notifyOfClosedObjectsChange(); + break; + case "TabSelect": + this.onTabSelect(win); + break; + case "TabShow": + this.onTabShow(win, target); + break; + case "TabHide": + this.onTabHide(win, target); + break; + case "TabPinned": + case "TabUnpinned": + case "SwapDocShells": + this.saveStateDelayed(win); + break; + case "oop-browser-crashed": + case "oop-browser-buildid-mismatch": + if (aEvent.isTopFrame) { + this.onBrowserCrashed(target); + } + break; + case "XULFrameLoaderCreated": + if ( + target.namespaceURI == XUL_NS && + target.localName == "browser" && + target.frameLoader && + target.permanentKey + ) { + this._lastKnownFrameLoader.set( + target.permanentKey, + target.frameLoader + ); + this.resetEpoch(target.permanentKey, target.frameLoader); + } + break; + default: + throw new Error(`unhandled event ${aEvent.type}?`); + } + this._clearRestoringWindows(); + }, + + /** + * Generate a unique window identifier + * @return string + * A unique string to identify a window + */ + _generateWindowID: function ssi_generateWindowID() { + return "window" + this._nextWindowID++; + }, + + /** + * Registers and tracks a given window. + * + * @param aWindow + * Window reference + */ + onLoad(aWindow) { + // return if window has already been initialized + if (aWindow && aWindow.__SSi && this._windows[aWindow.__SSi]) { + return; + } + + // ignore windows opened while shutting down + if (lazy.RunState.isQuitting) { + return; + } + + // Assign the window a unique identifier we can use to reference + // internal data about the window. + aWindow.__SSi = this._generateWindowID(); + + let mm = aWindow.getGroupMessageManager("browsers"); + MESSAGES.forEach(msg => { + let listenWhenClosed = CLOSED_MESSAGES.has(msg); + mm.addMessageListener(msg, this, listenWhenClosed); + }); + + // Load the frame script after registering listeners. + if (!Services.appinfo.sessionHistoryInParent) { + mm.loadFrameScript( + "chrome://browser/content/content-sessionStore.js", + true, + true + ); + } + + // and create its data object + this._windows[aWindow.__SSi] = { + tabs: [], + selected: 0, + _closedTabs: [], + _lastClosedTabGroupCount: -1, + busy: false, + chromeFlags: aWindow.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).chromeFlags, + }; + + if (PrivateBrowsingUtils.isWindowPrivate(aWindow)) { + this._windows[aWindow.__SSi].isPrivate = true; + } + if (!this._isWindowLoaded(aWindow)) { + this._windows[aWindow.__SSi]._restoring = true; + } + if (!aWindow.toolbar.visible) { + this._windows[aWindow.__SSi].isPopup = true; + } + + let tabbrowser = aWindow.gBrowser; + + // add tab change listeners to all already existing tabs + for (let i = 0; i < tabbrowser.tabs.length; i++) { + this.onTabBrowserInserted(aWindow, tabbrowser.tabs[i]); + } + // notification of tab add/remove/selection/show/hide + TAB_EVENTS.forEach(function(aEvent) { + tabbrowser.tabContainer.addEventListener(aEvent, this, true); + }, this); + + // Keep track of a browser's latest frameLoader. + aWindow.gBrowser.addEventListener("XULFrameLoaderCreated", this); + }, + + /** + * Initializes a given window. + * + * Windows are registered as soon as they are created but we need to wait for + * the session file to load, and the initial window's delayed startup to + * finish before initializing a window, i.e. restoring data into it. + * + * @param aWindow + * Window reference + * @param aInitialState + * The initial state to be loaded after startup (optional) + */ + initializeWindow(aWindow, aInitialState = null) { + let isPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(aWindow); + + // perform additional initialization when the first window is loading + if (lazy.RunState.isStopped) { + lazy.RunState.setRunning(); + + // restore a crashed session resp. resume the last session if requested + if (aInitialState) { + // Don't write to disk right after startup. Set the last time we wrote + // to disk to NOW() to enforce a full interval before the next write. + lazy.SessionSaver.updateLastSaveTime(); + + if (isPrivateWindow) { + // We're starting with a single private window. Save the state we + // actually wanted to restore so that we can do it later in case + // the user opens another, non-private window. + this._deferredInitialState = lazy.SessionStartup.state; + + // Nothing to restore now, notify observers things are complete. + Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED); + Services.obs.notifyObservers( + null, + "sessionstore-one-or-no-tab-restored" + ); + this._deferredAllWindowsRestored.resolve(); + } else { + TelemetryTimestamps.add("sessionRestoreRestoring"); + this._restoreCount = aInitialState.windows + ? aInitialState.windows.length + : 0; + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(aInitialState); + + // Restore session cookies before loading any tabs. + lazy.SessionCookies.restore(aInitialState.cookies || []); + + let overwrite = this._isCmdLineEmpty(aWindow, aInitialState); + let options = { firstWindow: true, overwriteTabs: overwrite }; + this.restoreWindows(aWindow, aInitialState, options); + } + } else { + // Nothing to restore, notify observers things are complete. + Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED); + Services.obs.notifyObservers( + null, + "sessionstore-one-or-no-tab-restored" + ); + this._deferredAllWindowsRestored.resolve(); + } + // this window was opened by _openWindowWithState + } else if (!this._isWindowLoaded(aWindow)) { + // We want to restore windows after all windows have opened (since bug + // 1034036), so bail out here. + return; + // The user opened another, non-private window after starting up with + // a single private one. Let's restore the session we actually wanted to + // restore at startup. + } else if ( + this._deferredInitialState && + !isPrivateWindow && + aWindow.toolbar.visible + ) { + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(this._deferredInitialState); + + this._restoreCount = this._deferredInitialState.windows + ? this._deferredInitialState.windows.length + : 0; + this.restoreWindows(aWindow, this._deferredInitialState, { + firstWindow: true, + }); + this._deferredInitialState = null; + } else if ( + this._restoreLastWindow && + aWindow.toolbar.visible && + this._closedWindows.length && + !isPrivateWindow + ) { + // default to the most-recently closed window + // don't use popup windows + let closedWindowState = null; + let closedWindowIndex; + for (let i = 0; i < this._closedWindows.length; i++) { + // Take the first non-popup, point our object at it, and break out. + if (!this._closedWindows[i].isPopup) { + closedWindowState = this._closedWindows[i]; + closedWindowIndex = i; + break; + } + } + + if (closedWindowState) { + let newWindowState; + if ( + AppConstants.platform == "macosx" || + !lazy.SessionStartup.willRestore() + ) { + // We want to split the window up into pinned tabs and unpinned tabs. + // Pinned tabs should be restored. If there are any remaining tabs, + // they should be added back to _closedWindows. + // We'll cheat a little bit and reuse _prepDataForDeferredRestore + // even though it wasn't built exactly for this. + let [ + appTabsState, + normalTabsState, + ] = this._prepDataForDeferredRestore({ + windows: [closedWindowState], + }); + + // These are our pinned tabs, which we should restore + if (appTabsState.windows.length) { + newWindowState = appTabsState.windows[0]; + delete newWindowState.__lastSessionWindowID; + } + + // In case there were no unpinned tabs, remove the window from _closedWindows + if (!normalTabsState.windows.length) { + this._removeClosedWindow(closedWindowIndex); + // Or update _closedWindows with the modified state + } else { + delete normalTabsState.windows[0].__lastSessionWindowID; + this._closedWindows[closedWindowIndex] = normalTabsState.windows[0]; + } + } else { + // If we're just restoring the window, make sure it gets removed from + // _closedWindows. + this._removeClosedWindow(closedWindowIndex); + newWindowState = closedWindowState; + delete newWindowState.hidden; + } + + if (newWindowState) { + // Ensure that the window state isn't hidden + this._restoreCount = 1; + let state = { windows: [newWindowState] }; + let options = { overwriteTabs: this._isCmdLineEmpty(aWindow, state) }; + this.restoreWindow(aWindow, newWindowState, options); + } + } + // we actually restored the session just now. + this._prefBranch.setBoolPref("sessionstore.resume_session_once", false); + } + if (this._restoreLastWindow && aWindow.toolbar.visible) { + // always reset (if not a popup window) + // we don't want to restore a window directly after, for example, + // undoCloseWindow was executed. + this._restoreLastWindow = false; + } + }, + + /** + * Called right before a new browser window is shown. + * @param aWindow + * Window reference + */ + onBeforeBrowserWindowShown(aWindow) { + // Register the window. + this.onLoad(aWindow); + + // Some are waiting for this window to be shown, which is now, so let's resolve + // the deferred operation. + let deferred = WINDOW_SHOWING_PROMISES.get(aWindow); + if (deferred) { + deferred.resolve(aWindow); + WINDOW_SHOWING_PROMISES.delete(aWindow); + } + + // Just call initializeWindow() directly if we're initialized already. + if (this._sessionInitialized) { + this.initializeWindow(aWindow); + return; + } + + // The very first window that is opened creates a promise that is then + // re-used by all subsequent windows. The promise will be used to tell + // when we're ready for initialization. + if (!this._promiseReadyForInitialization) { + // Wait for the given window's delayed startup to be finished. + let promise = new Promise(resolve => { + Services.obs.addObserver(function obs(subject, topic) { + if (aWindow == subject) { + Services.obs.removeObserver(obs, topic); + resolve(); + } + }, "browser-delayed-startup-finished"); + }); + + // We are ready for initialization as soon as the session file has been + // read from disk and the initial window's delayed startup has finished. + this._promiseReadyForInitialization = Promise.all([ + promise, + lazy.SessionStartup.onceInitialized, + ]); + } + + // We can't call this.onLoad since initialization + // hasn't completed, so we'll wait until it is done. + // Even if additional windows are opened and wait + // for initialization as well, the first opened + // window should execute first, and this.onLoad + // will be called with the initialState. + this._promiseReadyForInitialization + .then(() => { + if (aWindow.closed) { + return; + } + + if (this._sessionInitialized) { + this.initializeWindow(aWindow); + } else { + let initialState = this.initSession(); + this._sessionInitialized = true; + + if (initialState) { + Services.obs.notifyObservers(null, NOTIFY_RESTORING_ON_STARTUP); + } + TelemetryStopwatch.start( + "FX_SESSION_RESTORE_STARTUP_ONLOAD_INITIAL_WINDOW_MS" + ); + this.initializeWindow(aWindow, initialState); + TelemetryStopwatch.finish( + "FX_SESSION_RESTORE_STARTUP_ONLOAD_INITIAL_WINDOW_MS" + ); + + // Let everyone know we're done. + this._deferredInitialized.resolve(); + } + }) + .catch(console.error); + }, + + /** + * On window close... + * - remove event listeners from tabs + * - save all window data + * @param aWindow + * Window reference + * + * @returns a Promise + */ + onClose: function ssi_onClose(aWindow) { + let completionPromise = Promise.resolve(); + // this window was about to be restored - conserve its original data, if any + let isFullyLoaded = this._isWindowLoaded(aWindow); + if (!isFullyLoaded) { + if (!aWindow.__SSi) { + aWindow.__SSi = this._generateWindowID(); + } + + let restoreID = WINDOW_RESTORE_IDS.get(aWindow); + this._windows[aWindow.__SSi] = this._statesToRestore[ + restoreID + ].windows[0]; + delete this._statesToRestore[restoreID]; + WINDOW_RESTORE_IDS.delete(aWindow); + } + + // ignore windows not tracked by SessionStore + if (!aWindow.__SSi || !this._windows[aWindow.__SSi]) { + return completionPromise; + } + + // notify that the session store will stop tracking this window so that + // extensions can store any data about this window in session store before + // that's not possible anymore + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowClosing", true, false); + aWindow.dispatchEvent(event); + + if (this.windowToFocus && this.windowToFocus == aWindow) { + delete this.windowToFocus; + } + + var tabbrowser = aWindow.gBrowser; + + let browsers = Array.from(tabbrowser.browsers); + + TAB_EVENTS.forEach(function(aEvent) { + tabbrowser.tabContainer.removeEventListener(aEvent, this, true); + }, this); + + aWindow.gBrowser.removeEventListener("XULFrameLoaderCreated", this); + + let winData = this._windows[aWindow.__SSi]; + + // Collect window data only when *not* closed during shutdown. + if (lazy.RunState.isRunning) { + // Grab the most recent window data. The tab data will be updated + // once we finish flushing all of the messages from the tabs. + let tabMap = this._collectWindowData(aWindow); + + for (let [tab, tabData] of tabMap) { + let permanentKey = tab.linkedBrowser.permanentKey; + this._closedWindowTabs.set(permanentKey, tabData); + } + + if (isFullyLoaded && !winData.title) { + winData.title = + tabbrowser.selectedBrowser.contentTitle || + tabbrowser.selectedTab.label; + } + + if (AppConstants.platform != "macosx") { + // Until we decide otherwise elsewhere, this window is part of a series + // of closing windows to quit. + winData._shouldRestore = true; + } + + // Store the window's close date to figure out when each individual tab + // was closed. This timestamp should allow re-arranging data based on how + // recently something was closed. + winData.closedAt = Date.now(); + + // we don't want to save the busy state + delete winData.busy; + + // When closing windows one after the other until Firefox quits, we + // will move those closed in series back to the "open windows" bucket + // before writing to disk. If however there is only a single window + // with tabs we deem not worth saving then we might end up with a + // random closed or even a pop-up window re-opened. To prevent that + // we explicitly allow saving an "empty" window state. + let isLastWindow = this.isLastRestorableWindow(); + + // clear this window from the list, since it has definitely been closed. + delete this._windows[aWindow.__SSi]; + + // This window has the potential to be saved in the _closedWindows + // array (maybeSaveClosedWindows gets the final call on that). + this._saveableClosedWindowData.add(winData); + + // Now we have to figure out if this window is worth saving in the _closedWindows + // Object. + // + // We're about to flush the tabs from this window, but it's possible that we + // might never hear back from the content process(es) in time before the user + // chooses to restore the closed window. So we do the following: + // + // 1) Use the tab state cache to determine synchronously if the window is + // worth stashing in _closedWindows. + // 2) Flush the window. + // 3) When the flush is complete, revisit our decision to store the window + // in _closedWindows, and add/remove as necessary. + if (!winData.isPrivate) { + // Remove any open private tabs the window may contain. + lazy.PrivacyFilter.filterPrivateTabs(winData); + this.maybeSaveClosedWindow(winData, isLastWindow); + } + + completionPromise = lazy.TabStateFlusher.flushWindow(aWindow).then(() => { + // At this point, aWindow is closed! You should probably not try to + // access any DOM elements from aWindow within this callback unless + // you're holding on to them in the closure. + + WINDOW_FLUSHING_PROMISES.delete(aWindow); + + for (let browser of browsers) { + if (this._closedWindowTabs.has(browser.permanentKey)) { + let tabData = this._closedWindowTabs.get(browser.permanentKey); + lazy.TabState.copyFromCache(browser.permanentKey, tabData); + this._closedWindowTabs.delete(browser.permanentKey); + } + } + + // Save non-private windows if they have at + // least one saveable tab or are the last window. + if (!winData.isPrivate) { + // It's possible that a tab switched its privacy state at some point + // before our flush, so we need to filter again. + lazy.PrivacyFilter.filterPrivateTabs(winData); + this.maybeSaveClosedWindow(winData, isLastWindow); + } + + // Update the tabs data now that we've got the most + // recent information. + this.cleanUpWindow(aWindow, winData, browsers); + + // save the state without this window to disk + this.saveStateDelayed(); + }); + + // Here we might override a flush already in flight, but that's fine + // because `completionPromise` will always resolve after the old flush + // resolves. + WINDOW_FLUSHING_PROMISES.set(aWindow, completionPromise); + } else { + this.cleanUpWindow(aWindow, winData, browsers); + } + + for (let i = 0; i < tabbrowser.tabs.length; i++) { + this.onTabRemove(aWindow, tabbrowser.tabs[i], true); + } + + return completionPromise; + }, + + /** + * Clean up the message listeners on a window that has finally + * gone away. Call this once you're sure you don't want to hear + * from any of this windows tabs from here forward. + * + * @param aWindow + * The browser window we're cleaning up. + * @param winData + * The data for the window that we should hold in the + * DyingWindowCache in case anybody is still holding a + * reference to it. + */ + cleanUpWindow(aWindow, winData, browsers) { + // Any leftover TabStateFlusher Promises need to be resolved now, + // since we're about to remove the message listeners. + for (let browser of browsers) { + lazy.TabStateFlusher.resolveAll(browser); + } + + // Cache the window state until it is completely gone. + DyingWindowCache.set(aWindow, winData); + + let mm = aWindow.getGroupMessageManager("browsers"); + MESSAGES.forEach(msg => mm.removeMessageListener(msg, this)); + + this._saveableClosedWindowData.delete(winData); + delete aWindow.__SSi; + }, + + /** + * Decides whether or not a closed window should be put into the + * _closedWindows Object. This might be called multiple times per + * window, and will do the right thing of moving the window data + * in or out of _closedWindows if the winData indicates that our + * need for saving it has changed. + * + * @param winData + * The data for the closed window that we might save. + * @param isLastWindow + * Whether or not the window being closed is the last + * browser window. Callers of this function should pass + * in the value of SessionStoreInternal.atLastWindow for + * this argument, and pass in the same value if they happen + * to call this method again asynchronously (for example, after + * a window flush). + */ + maybeSaveClosedWindow(winData, isLastWindow) { + // Make sure SessionStore is still running, and make sure that we + // haven't chosen to forget this window. + if ( + lazy.RunState.isRunning && + this._saveableClosedWindowData.has(winData) + ) { + // Determine whether the window has any tabs worth saving. + let hasSaveableTabs = winData.tabs.some(this._shouldSaveTabState); + + // Note that we might already have this window stored in + // _closedWindows from a previous call to this function. + let winIndex = this._closedWindows.indexOf(winData); + let alreadyStored = winIndex != -1; + let shouldStore = hasSaveableTabs || isLastWindow; + + if (shouldStore && !alreadyStored) { + let index = this._closedWindows.findIndex(win => { + return win.closedAt < winData.closedAt; + }); + + // If we found no tab closed before our + // tab then just append it to the list. + if (index == -1) { + index = this._closedWindows.length; + } + + // About to save the closed window, add a unique ID. + winData.closedId = this._nextClosedId++; + + // Insert tabData at the right position. + this._closedWindows.splice(index, 0, winData); + this._capClosedWindows(); + this._closedObjectsChanged = true; + // The first time we close a window, ensure it can be restored from the + // hidden window. + if ( + AppConstants.platform == "macosx" && + this._closedWindows.length == 1 + ) { + // Fake a popupshowing event so shortcuts work: + let window = Services.appShell.hiddenDOMWindow; + let historyMenu = window.document.getElementById("history-menu"); + let evt = new window.CustomEvent("popupshowing", { bubbles: true }); + historyMenu.menupopup.dispatchEvent(evt); + } + } else if (!shouldStore && alreadyStored) { + this._removeClosedWindow(winIndex); + } + } + }, + + /** + * On quit application granted + */ + onQuitApplicationGranted: function ssi_onQuitApplicationGranted( + syncShutdown = false + ) { + // Collect an initial snapshot of window data before we do the flush. + let index = 0; + for (let window of this._orderedBrowserWindows) { + this._collectWindowData(window); + this._windows[window.__SSi].zIndex = ++index; + } + + // Now add an AsyncShutdown blocker that'll spin the event loop + // until the windows have all been flushed. + + // This progress object will track the state of async window flushing + // and will help us debug things that go wrong with our AsyncShutdown + // blocker. + let progress = { total: -1, current: -1 }; + + // We're going down! Switch state so that we treat closing windows and + // tabs correctly. + lazy.RunState.setQuitting(); + + if (!syncShutdown) { + // We've got some time to shut down, so let's do this properly that there + // will be a complete session available upon next startup. + // To prevent a blocker from taking longer than the DELAY_CRASH_MS limit + // (which will cause a crash) of AsyncShutdown whilst flushing all windows, + // we resolve the Promise blocker once: + // 1. the flush duration exceeds 10 seconds before DELAY_CRASH_MS, or + // 2. 'oop-frameloader-crashed', or + // 3. 'ipc:content-shutdown' is observed. + lazy.AsyncShutdown.quitApplicationGranted.addBlocker( + "SessionStore: flushing all windows", + () => { + // Set up the list of promises that will signal a complete sessionstore + // shutdown: either all data is saved, or we crashed or the message IPC + // channel went away in the meantime. + let promises = [this.flushAllWindowsAsync(progress)]; + + const observeTopic = topic => { + let deferred = lazy.PromiseUtils.defer(); + const observer = subject => { + // Skip abort on ipc:content-shutdown if not abnormal/crashed + subject.QueryInterface(Ci.nsIPropertyBag2); + if ( + !(topic == "ipc:content-shutdown" && !subject.get("abnormal")) + ) { + deferred.resolve(); + } + }; + const cleanup = () => { + try { + Services.obs.removeObserver(observer, topic); + } catch (ex) { + Cu.reportError( + "SessionStore: exception whilst flushing all windows: " + ex + ); + } + }; + Services.obs.addObserver(observer, topic); + deferred.promise.then(cleanup, cleanup); + return deferred; + }; + + // Build a list of deferred executions that require cleanup once the + // Promise race is won. + // Ensure that the timer fires earlier than the AsyncShutdown crash timer. + let waitTimeMaxMs = Math.max( + 0, + lazy.AsyncShutdown.DELAY_CRASH_MS - 10000 + ); + let defers = [ + this.looseTimer(waitTimeMaxMs), + + // FIXME: We should not be aborting *all* flushes when a single + // content process crashes here. + observeTopic("oop-frameloader-crashed"), + observeTopic("ipc:content-shutdown"), + ]; + // Add these monitors to the list of Promises to start the race. + promises.push(...defers.map(deferred => deferred.promise)); + + return Promise.race(promises).then(() => { + // When a Promise won the race, make sure we clean up the running + // monitors. + defers.forEach(deferred => deferred.reject()); + }); + }, + () => progress + ); + } else { + // We have to shut down NOW, which means we only get to save whatever + // we already had cached. + } + }, + + /** + * An async Task that iterates all open browser windows and flushes + * any outstanding messages from their tabs. This will also close + * all of the currently open windows while we wait for the flushes + * to complete. + * + * @param progress (Object) + * Optional progress object that will be updated as async + * window flushing progresses. flushAllWindowsSync will + * write to the following properties: + * + * total (int): + * The total number of windows to be flushed. + * current (int): + * The current window that we're waiting for a flush on. + * + * @return Promise + */ + async flushAllWindowsAsync(progress = {}) { + let windowPromises = new Map(WINDOW_FLUSHING_PROMISES); + WINDOW_FLUSHING_PROMISES.clear(); + + // We collect flush promises and close each window immediately so that + // the user can't start changing any window state while we're waiting + // for the flushes to finish. + for (let window of this._browserWindows) { + windowPromises.set(window, lazy.TabStateFlusher.flushWindow(window)); + + // We have to wait for these messages to come up from + // each window and each browser. In the meantime, hide + // the windows to improve perceived shutdown speed. + let baseWin = window.docShell.treeOwner.QueryInterface(Ci.nsIBaseWindow); + baseWin.visibility = false; + } + + progress.total = windowPromises.size; + progress.current = 0; + + // We'll iterate through the Promise array, yielding each one, so as to + // provide useful progress information to AsyncShutdown. + for (let [win, promise] of windowPromises) { + await promise; + + // We may have already stopped tracking this window in onClose, which is + // fine as we would've collected window data there as well. + if (win.__SSi && this._windows[win.__SSi]) { + this._collectWindowData(win); + } + + progress.current++; + } + + // We must cache this because _getTopWindow will always + // return null by the time quit-application occurs. + var activeWindow = this._getTopWindow(); + if (activeWindow) { + this.activeWindowSSiCache = activeWindow.__SSi || ""; + } + DirtyWindows.clear(); + }, + + /** + * On last browser window close + */ + onLastWindowCloseGranted: function ssi_onLastWindowCloseGranted() { + // last browser window is quitting. + // remember to restore the last window when another browser window is opened + // do not account for pref(resume_session_once) at this point, as it might be + // set by another observer getting this notice after us + this._restoreLastWindow = true; + }, + + /** + * On quitting application + * @param aData + * String type of quitting + */ + onQuitApplication: function ssi_onQuitApplication(aData) { + if (aData == "restart" || aData == "os-restart") { + if (!PrivateBrowsingUtils.permanentPrivateBrowsing) { + if ( + aData == "os-restart" && + !this._prefBranch.getBoolPref("sessionstore.resume_session_once") + ) { + this._prefBranch.setBoolPref( + "sessionstore.resuming_after_os_restart", + true + ); + } + this._prefBranch.setBoolPref("sessionstore.resume_session_once", true); + } + + // The browser:purge-session-history notification fires after the + // quit-application notification so unregister the + // browser:purge-session-history notification to prevent clearing + // session data on disk on a restart. It is also unnecessary to + // perform any other sanitization processing on a restart as the + // browser is about to exit anyway. + Services.obs.removeObserver(this, "browser:purge-session-history"); + } + + if (aData != "restart") { + // Throw away the previous session on shutdown without notification + LastSession.clear(true); + } + + this._uninit(); + }, + + /** + * On purge of session history + */ + onPurgeSessionHistory: function ssi_onPurgeSessionHistory() { + lazy.SessionFile.wipe(); + // If the browser is shutting down, simply return after clearing the + // session data on disk as this notification fires after the + // quit-application notification so the browser is about to exit. + if (lazy.RunState.isQuitting) { + return; + } + LastSession.clear(); + + let openWindows = {}; + // Collect open windows. + for (let window of this._browserWindows) { + openWindows[window.__SSi] = true; + } + + // also clear all data about closed tabs and windows + for (let ix in this._windows) { + if (ix in openWindows) { + if (this._windows[ix]._closedTabs.length) { + this._windows[ix]._closedTabs = []; + this._closedObjectsChanged = true; + } + } else { + delete this._windows[ix]; + } + } + // also clear all data about closed windows + if (this._closedWindows.length) { + this._closedWindows = []; + this._closedObjectsChanged = true; + } + // give the tabbrowsers a chance to clear their histories first + var win = this._getTopWindow(); + if (win) { + win.setTimeout(() => lazy.SessionSaver.run(), 0); + } else if (lazy.RunState.isRunning) { + lazy.SessionSaver.run(); + } + + this._clearRestoringWindows(); + this._saveableClosedWindowData = new WeakSet(); + }, + + /** + * On purge of domain data + * @param {string} aDomain + * The domain we want to purge data for + */ + onPurgeDomainData: function ssi_onPurgeDomainData(aDomain) { + // does a session history entry contain a url for the given domain? + function containsDomain(aEntry) { + let host; + try { + host = Services.io.newURI(aEntry.url).host; + } catch (e) { + // The given URL probably doesn't have a host. + } + if (host && Services.eTLD.hasRootDomain(host, aDomain)) { + return true; + } + return aEntry.children && aEntry.children.some(containsDomain, this); + } + // remove all closed tabs containing a reference to the given domain + for (let ix in this._windows) { + let closedTabs = this._windows[ix]._closedTabs; + for (let i = closedTabs.length - 1; i >= 0; i--) { + if (closedTabs[i].state.entries.some(containsDomain, this)) { + closedTabs.splice(i, 1); + this._closedObjectsChanged = true; + } + } + } + // remove all open & closed tabs containing a reference to the given + // domain in closed windows + for (let ix = this._closedWindows.length - 1; ix >= 0; ix--) { + let closedTabs = this._closedWindows[ix]._closedTabs; + let openTabs = this._closedWindows[ix].tabs; + let openTabCount = openTabs.length; + for (let i = closedTabs.length - 1; i >= 0; i--) { + if (closedTabs[i].state.entries.some(containsDomain, this)) { + closedTabs.splice(i, 1); + } + } + for (let j = openTabs.length - 1; j >= 0; j--) { + if (openTabs[j].entries.some(containsDomain, this)) { + openTabs.splice(j, 1); + if (this._closedWindows[ix].selected > j) { + this._closedWindows[ix].selected--; + } + } + } + if (!openTabs.length) { + this._closedWindows.splice(ix, 1); + } else if (openTabs.length != openTabCount) { + // Adjust the window's title if we removed an open tab + let selectedTab = openTabs[this._closedWindows[ix].selected - 1]; + // some duplication from restoreHistory - make sure we get the correct title + let activeIndex = (selectedTab.index || selectedTab.entries.length) - 1; + if (activeIndex >= selectedTab.entries.length) { + activeIndex = selectedTab.entries.length - 1; + } + this._closedWindows[ix].title = selectedTab.entries[activeIndex].title; + } + } + + if (lazy.RunState.isRunning) { + lazy.SessionSaver.run(); + } + + this._clearRestoringWindows(); + }, + + /** + * On preference change + * @param aData + * String preference changed + */ + onPrefChange: function ssi_onPrefChange(aData) { + switch (aData) { + // if the user decreases the max number of closed tabs they want + // preserved update our internal states to match that max + case "sessionstore.max_tabs_undo": + this._max_tabs_undo = this._prefBranch.getIntPref( + "sessionstore.max_tabs_undo" + ); + for (let ix in this._windows) { + if (this._windows[ix]._closedTabs.length > this._max_tabs_undo) { + this._windows[ix]._closedTabs.splice( + this._max_tabs_undo, + this._windows[ix]._closedTabs.length + ); + this._closedObjectsChanged = true; + } + } + break; + case "sessionstore.max_windows_undo": + this._max_windows_undo = this._prefBranch.getIntPref( + "sessionstore.max_windows_undo" + ); + this._capClosedWindows(); + break; + case "privacy.resistFingerprinting": + gResistFingerprintingEnabled = Services.prefs.getBoolPref( + "privacy.resistFingerprinting" + ); + break; + case "sessionstore.restore_on_demand": + this._restore_on_demand = this._prefBranch.getBoolPref( + "sessionstore.restore_on_demand" + ); + break; + } + }, + + /** + * save state when new tab is added + * @param aWindow + * Window reference + */ + onTabAdd: function ssi_onTabAdd(aWindow) { + this.saveStateDelayed(aWindow); + }, + + /** + * set up listeners for a new tab + * @param aWindow + * Window reference + * @param aTab + * Tab reference + */ + onTabBrowserInserted: function ssi_onTabBrowserInserted(aWindow, aTab) { + let browser = aTab.linkedBrowser; + browser.addEventListener("SwapDocShells", this); + browser.addEventListener("oop-browser-crashed", this); + browser.addEventListener("oop-browser-buildid-mismatch", this); + + if (browser.frameLoader) { + this._lastKnownFrameLoader.set(browser.permanentKey, browser.frameLoader); + } + + // Only restore if browser has been lazy. + if ( + TAB_LAZY_STATES.has(aTab) && + !TAB_STATE_FOR_BROWSER.has(browser) && + lazy.TabStateCache.get(browser.permanentKey) + ) { + let tabState = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); + this.restoreTab(aTab, tabState); + } + + // The browser has been inserted now, so lazy data is no longer relevant. + TAB_LAZY_STATES.delete(aTab); + }, + + /** + * remove listeners for a tab + * @param aWindow + * Window reference + * @param aTab + * Tab reference + * @param aNoNotification + * bool Do not save state if we're updating an existing tab + */ + onTabRemove: function ssi_onTabRemove(aWindow, aTab, aNoNotification) { + this.cleanUpRemovedBrowser(aTab); + + if (!aNoNotification) { + this.saveStateDelayed(aWindow); + } + }, + + /** + * When a tab closes, collect its properties + * @param aWindow + * Window reference + * @param aTab + * Tab reference + */ + onTabClose: function ssi_onTabClose(aWindow, aTab) { + // notify the tabbrowser that the tab state will be retrieved for the last time + // (so that extension authors can easily set data on soon-to-be-closed tabs) + var event = aWindow.document.createEvent("Events"); + event.initEvent("SSTabClosing", true, false); + aTab.dispatchEvent(event); + + // don't update our internal state if we don't have to + if (this._max_tabs_undo == 0) { + return; + } + + // Get the latest data for this tab (generally, from the cache) + let tabState = lazy.TabState.collect(aTab, TAB_CUSTOM_VALUES.get(aTab)); + + // Store closed-tab data for undo. + this.maybeSaveClosedTab(aWindow, aTab, tabState); + }, + + /** + * Flush and copy tab state when moving a tab to a new window. + * @param aFromBrowser + * Browser reference. + * @param aToBrowser + * Browser reference. + */ + onMoveToNewWindow(aFromBrowser, aToBrowser) { + lazy.TabStateFlusher.flush(aFromBrowser).then(() => { + let tabState = lazy.TabStateCache.get(aFromBrowser.permanentKey); + lazy.TabStateCache.update(aToBrowser.permanentKey, tabState); + }); + }, + + /** + * Save a closed tab if needed. + * @param aWindow + * Window reference. + * @param aTab + * Tab reference. + * @param tabState + * Tab state. + */ + maybeSaveClosedTab(aWindow, aTab, tabState) { + // Don't save private tabs + let isPrivateWindow = PrivateBrowsingUtils.isWindowPrivate(aWindow); + if (!isPrivateWindow && tabState.isPrivate) { + return; + } + if (aTab == aWindow.FirefoxViewHandler.tab) { + return; + } + + let permanentKey = aTab.linkedBrowser.permanentKey; + + let tabData = { + permanentKey, + state: tabState, + title: aTab.label, + image: aWindow.gBrowser.getIcon(aTab), + pos: aTab._tPos, + closedAt: Date.now(), + closedInGroup: aTab._closedInGroup, + }; + + let winData = this._windows[aWindow.__SSi]; + let closedTabs = winData._closedTabs; + + // Determine whether the tab contains any information worth saving. Note + // that there might be pending state changes queued in the child that + // didn't reach the parent yet. If a tab is emptied before closing then we + // might still remove it from the list of closed tabs later. + if (this._shouldSaveTabState(tabState)) { + // Save the tab state, for now. We might push a valid tab out + // of the list but those cases should be extremely rare and + // do probably never occur when using the browser normally. + // (Tests or add-ons might do weird things though.) + this.saveClosedTabData(winData, closedTabs, tabData); + } + + // Remember the closed tab to properly handle any last updates included in + // the final "update" message sent by the frame script's unload handler. + this._closedTabs.set(permanentKey, { winData, closedTabs, tabData }); + }, + + /** + * Remove listeners which were added when browser was inserted and reset restoring state. + * Also re-instate lazy data and basically revert tab to its lazy browser state. + * @param aTab + * Tab reference + */ + resetBrowserToLazyState(aTab) { + const gBrowser = aTab.ownerGlobal.gBrowser; + let browser = aTab.linkedBrowser; + // Browser is already lazy so don't do anything. + if (!browser.isConnected) { + return; + } + + this.cleanUpRemovedBrowser(aTab); + + aTab.setAttribute("pending", "true"); + + this._lastKnownFrameLoader.delete(browser.permanentKey); + this._crashedBrowsers.delete(browser.permanentKey); + aTab.removeAttribute("crashed"); + gBrowser.tabContainer.updateTabIndicatorAttr(aTab); + + let { userTypedValue = null, userTypedClear = 0 } = browser; + let hasStartedLoad = browser.didStartLoadSinceLastUserTyping(); + + let cacheState = lazy.TabStateCache.get(browser.permanentKey); + + // Cache the browser userTypedValue either if there is no cache state + // at all (e.g. if it was already discarded before we got to cache its state) + // or it may have been created but not including a userTypedValue (e.g. + // for a private tab we will cache `isPrivate: true` as soon as the tab + // is opened). + // + // But only if: + // + // - if there is no cache state yet (which is unfortunately required + // for tabs discarded immediately after creation by extensions, see + // Bug 1422588). + // + // - or the user typed value was already being loaded (otherwise the lazy + // tab will not be restored with the expected url once activated again, + // see Bug 1724205). + let shouldUpdateCacheState = + userTypedValue && + (!cacheState || (hasStartedLoad && !cacheState.userTypedValue)); + + if (shouldUpdateCacheState) { + // Discard was likely called before state can be cached. Update + // the persistent tab state cache with browser information so a + // restore will be successful. This information is necessary for + // restoreTabContent in ContentRestore.sys.mjs to work properly. + lazy.TabStateCache.update(browser.permanentKey, { + userTypedValue, + userTypedClear: 1, + }); + } + + TAB_LAZY_STATES.set(aTab, { + url: browser.currentURI.spec, + title: aTab.label, + userTypedValue, + userTypedClear, + }); + }, + + /** + * Check if we are dealing with a crashed browser. If so, then the corresponding + * crashed tab was revived by navigating to a different page. Remove the browser + * from the list of crashed browsers to stop ignoring its messages. + * @param aBrowser + * Browser reference + */ + maybeExitCrashedState(aBrowser) { + let uri = aBrowser.documentURI; + if (uri?.spec?.startsWith("about:tabcrashed")) { + this._crashedBrowsers.delete(aBrowser.permanentKey); + } + }, + + /** + * A debugging-only function to check if a browser is in _crashedBrowsers. + * @param aBrowser + * Browser reference + */ + isBrowserInCrashedSet(aBrowser) { + if (gDebuggingEnabled) { + return this._crashedBrowsers.has(aBrowser.permanentKey); + } + throw new Error( + "SessionStore.isBrowserInCrashedSet() should only be called in debug mode!" + ); + }, + + /** + * When a tab is removed or suspended, remove listeners and reset restoring state. + * @param aBrowser + * Browser reference + */ + cleanUpRemovedBrowser(aTab) { + let browser = aTab.linkedBrowser; + + browser.removeEventListener("SwapDocShells", this); + browser.removeEventListener("oop-browser-crashed", this); + browser.removeEventListener("oop-browser-buildid-mismatch", this); + + // If this tab was in the middle of restoring or still needs to be restored, + // we need to reset that state. If the tab was restoring, we will attempt to + // restore the next tab. + let previousState = TAB_STATE_FOR_BROWSER.get(browser); + if (previousState) { + this._resetTabRestoringState(aTab); + if (previousState == TAB_STATE_RESTORING) { + this.restoreNextTab(); + } + } + }, + + /** + * Insert a given |tabData| object into the list of |closedTabs|. We will + * determine the right insertion point based on the .closedAt properties of + * all tabs already in the list. The list will be truncated to contain a + * maximum of |this._max_tabs_undo| entries. + * + * @param winData (object) + * The data of the window. + * @param tabData (object) + * The tabData to be inserted. + * @param closedTabs (array) + * The list of closed tabs for a window. + */ + saveClosedTabData(winData, closedTabs, tabData) { + // Find the index of the first tab in the list + // of closed tabs that was closed before our tab. + let index = closedTabs.findIndex(tab => { + return tab.closedAt < tabData.closedAt; + }); + + // If we found no tab closed before our + // tab then just append it to the list. + if (index == -1) { + index = closedTabs.length; + } + + // About to save the closed tab, add a unique ID. + tabData.closedId = this._nextClosedId++; + + // Insert tabData at the right position. + closedTabs.splice(index, 0, tabData); + this._closedObjectsChanged = true; + + if (tabData.closedInGroup) { + if (winData._lastClosedTabGroupCount < this._max_tabs_undo) { + if (winData._lastClosedTabGroupCount < 0) { + winData._lastClosedTabGroupCount = 1; + } else { + winData._lastClosedTabGroupCount++; + } + } + } else { + winData._lastClosedTabGroupCount = -1; + } + + // Truncate the list of closed tabs, if needed. + if (closedTabs.length > this._max_tabs_undo) { + closedTabs.splice(this._max_tabs_undo, closedTabs.length); + } + }, + + /** + * Remove the closed tab data at |index| from the list of |closedTabs|. If + * the tab's final message is still pending we will simply discard it when + * it arrives so that the tab doesn't reappear in the list. + * + * @param winData (object) + * The data of the window. + * @param index (uint) + * The index of the tab to remove. + * @param closedTabs (array) + * The list of closed tabs for a window. + */ + removeClosedTabData(winData, closedTabs, index) { + // Remove the given index from the list. + let [closedTab] = closedTabs.splice(index, 1); + this._closedObjectsChanged = true; + + // If the tab is part of the last closed group, + // we need to deduct the tab from the count. + if (index < winData._lastClosedTabGroupCount) { + winData._lastClosedTabGroupCount--; + } + + // If the closed tab's state still has a .permanentKey property then we + // haven't seen its final update message yet. Remove it from the map of + // closed tabs so that we will simply discard its last messages and will + // not add it back to the list of closed tabs again. + if (closedTab.permanentKey) { + this._closedTabs.delete(closedTab.permanentKey); + this._closedWindowTabs.delete(closedTab.permanentKey); + delete closedTab.permanentKey; + } + + return closedTab; + }, + + /** + * When a tab is selected, save session data + * @param aWindow + * Window reference + */ + onTabSelect: function ssi_onTabSelect(aWindow) { + if (lazy.RunState.isRunning) { + this._windows[aWindow.__SSi].selected = + aWindow.gBrowser.tabContainer.selectedIndex; + + let tab = aWindow.gBrowser.selectedTab; + let browser = tab.linkedBrowser; + + if (TAB_STATE_FOR_BROWSER.get(browser) == TAB_STATE_NEEDS_RESTORE) { + // If BROWSER_STATE is still available for the browser and it is + // If __SS_restoreState is still on the browser and it is + // TAB_STATE_NEEDS_RESTORE, then we haven't restored this tab yet. + // + // It's possible that this tab was recently revived, and that + // we've deferred showing the tab crashed page for it (if the + // tab crashed in the background). If so, we need to re-enter + // the crashed state, since we'll be showing the tab crashed + // page. + if (lazy.TabCrashHandler.willShowCrashedTab(browser)) { + this.enterCrashedState(browser); + } else { + this.restoreTabContent(tab); + } + } + } + }, + + onTabShow: function ssi_onTabShow(aWindow, aTab) { + // If the tab hasn't been restored yet, move it into the right bucket + if ( + TAB_STATE_FOR_BROWSER.get(aTab.linkedBrowser) == TAB_STATE_NEEDS_RESTORE + ) { + TabRestoreQueue.hiddenToVisible(aTab); + + // let's kick off tab restoration again to ensure this tab gets restored + // with "restore_hidden_tabs" == false (now that it has become visible) + this.restoreNextTab(); + } + + // Default delay of 2 seconds gives enough time to catch multiple TabShow + // events. This used to be due to changing groups in 'tab groups'. We + // might be able to get rid of this now? + this.saveStateDelayed(aWindow); + }, + + onTabHide: function ssi_onTabHide(aWindow, aTab) { + // If the tab hasn't been restored yet, move it into the right bucket + if ( + TAB_STATE_FOR_BROWSER.get(aTab.linkedBrowser) == TAB_STATE_NEEDS_RESTORE + ) { + TabRestoreQueue.visibleToHidden(aTab); + } + + // Default delay of 2 seconds gives enough time to catch multiple TabHide + // events. This used to be due to changing groups in 'tab groups'. We + // might be able to get rid of this now? + this.saveStateDelayed(aWindow); + }, + + /** + * Handler for the event that is fired when a <xul:browser> crashes. + * + * @param aWindow + * The window that the crashed browser belongs to. + * @param aBrowser + * The <xul:browser> that is now in the crashed state. + */ + onBrowserCrashed(aBrowser) { + this.enterCrashedState(aBrowser); + // The browser crashed so we might never receive flush responses. + // Resolve all pending flush requests for the crashed browser. + lazy.TabStateFlusher.resolveAll(aBrowser); + }, + + /** + * Called when a browser is showing or is about to show the tab + * crashed page. This method causes SessionStore to ignore the + * tab until it's restored. + * + * @param browser + * The <xul:browser> that is about to show the crashed page. + */ + enterCrashedState(browser) { + this._crashedBrowsers.add(browser.permanentKey); + + let win = browser.ownerGlobal; + + // If we hadn't yet restored, or were still in the midst of + // restoring this browser at the time of the crash, we need + // to reset its state so that we can try to restore it again + // when the user revives the tab from the crash. + if (TAB_STATE_FOR_BROWSER.has(browser)) { + let tab = win.gBrowser.getTabForBrowser(browser); + if (tab) { + this._resetLocalTabRestoringState(tab); + } + } + }, + + // Clean up data that has been closed a long time ago. + // Do not reschedule a save. This will wait for the next regular + // save. + onIdleDaily() { + // Remove old closed windows + this._cleanupOldData([this._closedWindows]); + + // Remove closed tabs of closed windows + this._cleanupOldData( + this._closedWindows.map(winData => winData._closedTabs) + ); + + // Remove closed tabs of open windows + this._cleanupOldData( + Object.keys(this._windows).map(key => this._windows[key]._closedTabs) + ); + + this._notifyOfClosedObjectsChange(); + }, + + // Remove "old" data from an array + _cleanupOldData(targets) { + const TIME_TO_LIVE = this._prefBranch.getIntPref( + "sessionstore.cleanup.forget_closed_after" + ); + const now = Date.now(); + + for (let array of targets) { + for (let i = array.length - 1; i >= 0; --i) { + let data = array[i]; + // Make sure that we have a timestamp to tell us when the target + // has been closed. If we don't have a timestamp, default to a + // safe timestamp: just now. + data.closedAt = data.closedAt || now; + if (now - data.closedAt > TIME_TO_LIVE) { + array.splice(i, 1); + this._closedObjectsChanged = true; + } + } + } + }, + + /* ........ nsISessionStore API .............. */ + + getBrowserState: function ssi_getBrowserState() { + let state = this.getCurrentState(); + + // Don't include the last session state in getBrowserState(). + delete state.lastSessionState; + + // Don't include any deferred initial state. + delete state.deferredInitialState; + + return JSON.stringify(state); + }, + + setBrowserState: function ssi_setBrowserState(aState) { + this._handleClosedWindows(); + + try { + var state = JSON.parse(aState); + } catch (ex) { + /* invalid state object - don't restore anything */ + } + if (!state) { + throw Components.Exception( + "Invalid state string: not JSON", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (!state.windows) { + throw Components.Exception("No windows", Cr.NS_ERROR_INVALID_ARG); + } + + this._browserSetState = true; + + // Make sure the priority queue is emptied out + this._resetRestoringState(); + + var window = this._getTopWindow(); + if (!window) { + this._restoreCount = 1; + this._openWindowWithState(state); + return; + } + + // close all other browser windows + for (let otherWin of this._browserWindows) { + if (otherWin != window) { + otherWin.close(); + this.onClose(otherWin); + } + } + + // make sure closed window data isn't kept + if (this._closedWindows.length) { + this._closedWindows = []; + this._closedObjectsChanged = true; + } + + // determine how many windows are meant to be restored + this._restoreCount = state.windows ? state.windows.length : 0; + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(state); + + // Restore session cookies. + lazy.SessionCookies.restore(state.cookies || []); + + // restore to the given state + this.restoreWindows(window, state, { overwriteTabs: true }); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getWindowState: function ssi_getWindowState(aWindow) { + if ("__SSi" in aWindow) { + return Cu.cloneInto(this._getWindowState(aWindow), {}); + } + + if (DyingWindowCache.has(aWindow)) { + let data = DyingWindowCache.get(aWindow); + return Cu.cloneInto({ windows: [data] }, {}); + } + + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + }, + + setWindowState: function ssi_setWindowState(aWindow, aState, aOverwrite) { + if (!aWindow.__SSi) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.restoreWindows(aWindow, aState, { overwriteTabs: aOverwrite }); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getTabState: function ssi_getTabState(aTab) { + if (!aTab || !aTab.ownerGlobal) { + throw Components.Exception("Need a valid tab", Cr.NS_ERROR_INVALID_ARG); + } + if (!aTab.ownerGlobal.__SSi) { + throw Components.Exception( + "Default view is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let tabState = lazy.TabState.collect(aTab, TAB_CUSTOM_VALUES.get(aTab)); + + return JSON.stringify(tabState); + }, + + setTabState(aTab, aState) { + // Remove the tab state from the cache. + // Note that we cannot simply replace the contents of the cache + // as |aState| can be an incomplete state that will be completed + // by |restoreTabs|. + let tabState = aState; + if (typeof tabState == "string") { + tabState = JSON.parse(aState); + } + if (!tabState) { + throw Components.Exception( + "Invalid state string: not JSON", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (typeof tabState != "object") { + throw Components.Exception("Not an object", Cr.NS_ERROR_INVALID_ARG); + } + if (!("entries" in tabState)) { + throw Components.Exception( + "Invalid state object: no entries", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let window = aTab.ownerGlobal; + if (!window || !("__SSi" in window)) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + if (TAB_STATE_FOR_BROWSER.has(aTab.linkedBrowser)) { + this._resetTabRestoringState(aTab); + } + + this.restoreTab(aTab, tabState); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getInternalObjectState(obj) { + if (obj.__SSi) { + return this._windows[obj.__SSi]; + } + return obj.loadURI + ? TAB_STATE_FOR_BROWSER.get(obj) + : TAB_CUSTOM_VALUES.get(obj); + }, + + duplicateTab: function ssi_duplicateTab( + aWindow, + aTab, + aDelta = 0, + aRestoreImmediately = true, + { inBackground, index } = {} + ) { + if (!aTab || !aTab.ownerGlobal) { + throw Components.Exception("Need a valid tab", Cr.NS_ERROR_INVALID_ARG); + } + if (!aTab.ownerGlobal.__SSi) { + throw Components.Exception( + "Default view is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (!aWindow.gBrowser) { + throw Components.Exception( + "Invalid window object: no gBrowser", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // Create a new tab. + let userContextId = aTab.getAttribute("usercontextid"); + + let tabOptions = { + userContextId, + index, + ...(aTab == aWindow.gBrowser.selectedTab + ? { relatedToCurrent: true, ownerTab: aTab } + : {}), + skipLoad: true, + preferredRemoteType: aTab.linkedBrowser.remoteType, + }; + let newTab = aWindow.gBrowser.addTrustedTab(null, tabOptions); + + // Start the throbber to pretend we're doing something while actually + // waiting for data from the frame script. This throbber is disabled + // if the URI is a local about: URI. + let uriObj = aTab.linkedBrowser.currentURI; + if (!uriObj || (uriObj && !uriObj.schemeIs("about"))) { + newTab.setAttribute("busy", "true"); + } + + // Hack to ensure that the about:home, about:newtab, and about:welcome + // favicon is loaded instantaneously, to avoid flickering and improve + // perceived performance. + aWindow.gBrowser.setDefaultIcon(newTab, uriObj); + + // Collect state before flushing. + let tabState = lazy.TabState.collect(aTab, TAB_CUSTOM_VALUES.get(aTab)); + + // Flush to get the latest tab state to duplicate. + let browser = aTab.linkedBrowser; + lazy.TabStateFlusher.flush(browser).then(() => { + // The new tab might have been closed in the meantime. + if (newTab.closing || !newTab.linkedBrowser) { + return; + } + + let window = newTab.ownerGlobal; + + // The tab or its window might be gone. + if (!window || !window.__SSi) { + return; + } + + // Update state with flushed data. We can't use TabState.clone() here as + // the tab to duplicate may have already been closed. In that case we + // only have access to the <xul:browser>. + let options = { includePrivateData: true }; + lazy.TabState.copyFromCache(browser.permanentKey, tabState, options); + + tabState.index += aDelta; + tabState.index = Math.max( + 1, + Math.min(tabState.index, tabState.entries.length) + ); + tabState.pinned = false; + + if (inBackground === false) { + aWindow.gBrowser.selectedTab = newTab; + } + + // Restore the state into the new tab. + this.restoreTab(newTab, tabState, { + restoreImmediately: aRestoreImmediately, + }); + }); + + return newTab; + }, + + getLastClosedTabCount(aWindow) { + if ("__SSi" in aWindow) { + return Math.min( + Math.max(this._windows[aWindow.__SSi]._lastClosedTabGroupCount, 1), + this.getClosedTabCount(aWindow) + ); + } + + throw (Components.returnCode = Cr.NS_ERROR_INVALID_ARG); + }, + + resetLastClosedTabCount(aWindow) { + if ("__SSi" in aWindow) { + this._windows[aWindow.__SSi]._lastClosedTabGroupCount = -1; + } else { + throw (Components.returnCode = Cr.NS_ERROR_INVALID_ARG); + } + }, + + getClosedTabCount: function ssi_getClosedTabCount(aWindow) { + if ("__SSi" in aWindow) { + return this._windows[aWindow.__SSi]._closedTabs.length; + } + + if (!DyingWindowCache.has(aWindow)) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + return DyingWindowCache.get(aWindow)._closedTabs.length; + }, + + getClosedTabData: function ssi_getClosedTabData(aWindow) { + if ("__SSi" in aWindow) { + return Cu.cloneInto(this._windows[aWindow.__SSi]._closedTabs, {}); + } + + if (!DyingWindowCache.has(aWindow)) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let data = DyingWindowCache.get(aWindow); + return Cu.cloneInto(data._closedTabs, {}); + }, + + undoCloseTab: function ssi_undoCloseTab(aWindow, aIndex) { + if (!aWindow.__SSi) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let winData = this._windows[aWindow.__SSi]; + + // default to the most-recently closed tab + aIndex = aIndex || 0; + if (!(aIndex in winData._closedTabs)) { + throw Components.Exception( + "Invalid index: not in the closed tabs", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // fetch the data of closed tab, while removing it from the array + let { state, pos } = this.removeClosedTabData( + winData, + winData._closedTabs, + aIndex + ); + + // Predict the remote type to use for the load to avoid unnecessary process + // switches. + let preferredRemoteType = lazy.E10SUtils.DEFAULT_REMOTE_TYPE; + if (state.entries?.length) { + let activeIndex = (state.index || state.entries.length) - 1; + activeIndex = Math.min(activeIndex, state.entries.length - 1); + activeIndex = Math.max(activeIndex, 0); + + preferredRemoteType = lazy.E10SUtils.getRemoteTypeForURI( + state.entries[activeIndex].url, + aWindow.gMultiProcessBrowser, + aWindow.gFissionBrowser, + lazy.E10SUtils.DEFAULT_REMOTE_TYPE, + null, + lazy.E10SUtils.predictOriginAttributes({ + window: aWindow, + userContextId: state.userContextId, + }) + ); + } + + // create a new tab + let tabbrowser = aWindow.gBrowser; + let tab = (tabbrowser.selectedTab = tabbrowser.addTrustedTab(null, { + index: pos, + pinned: state.pinned, + userContextId: state.userContextId, + skipLoad: true, + preferredRemoteType, + })); + + // restore tab content + this.restoreTab(tab, state); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + + return tab; + }, + + forgetClosedTab: function ssi_forgetClosedTab(aWindow, aIndex) { + if (!aWindow.__SSi) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let winData = this._windows[aWindow.__SSi]; + + // default to the most-recently closed tab + aIndex = aIndex || 0; + if (!(aIndex in winData._closedTabs)) { + throw Components.Exception( + "Invalid index: not in the closed tabs", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // remove closed tab from the array + this.removeClosedTabData(winData, winData._closedTabs, aIndex); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getClosedWindowCount: function ssi_getClosedWindowCount() { + return this._closedWindows.length; + }, + + getClosedWindowData: function ssi_getClosedWindowData() { + return Cu.cloneInto(this._closedWindows, {}); + }, + + maybeDontRestoreTabs(aWindow) { + // Don't restore the tabs if we restore the session at startup + this._windows[aWindow.__SSi]._maybeDontRestoreTabs = true; + }, + + isLastRestorableWindow() { + return ( + Object.values(this._windows).filter(winData => !winData.isPrivate) + .length == 1 && + !this._closedWindows.some(win => win._shouldRestore || false) + ); + }, + + undoCloseWindow: function ssi_undoCloseWindow(aIndex) { + if (!(aIndex in this._closedWindows)) { + throw Components.Exception( + "Invalid index: not in the closed windows", + Cr.NS_ERROR_INVALID_ARG + ); + } + // reopen the window + let state = { windows: this._removeClosedWindow(aIndex) }; + delete state.windows[0].closedAt; // Window is now open. + + let window = this._openWindowWithState(state); + this.windowToFocus = window; + WINDOW_SHOWING_PROMISES.get(window).promise.then(win => + this.restoreWindows(win, state, { overwriteTabs: true }) + ); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + + return window; + }, + + forgetClosedWindow: function ssi_forgetClosedWindow(aIndex) { + // default to the most-recently closed window + aIndex = aIndex || 0; + if (!(aIndex in this._closedWindows)) { + throw Components.Exception( + "Invalid index: not in the closed windows", + Cr.NS_ERROR_INVALID_ARG + ); + } + + // remove closed window from the array + let winData = this._closedWindows[aIndex]; + this._removeClosedWindow(aIndex); + this._saveableClosedWindowData.delete(winData); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + getCustomWindowValue(aWindow, aKey) { + if ("__SSi" in aWindow) { + let data = this._windows[aWindow.__SSi].extData || {}; + return data[aKey] || ""; + } + + if (DyingWindowCache.has(aWindow)) { + let data = DyingWindowCache.get(aWindow).extData || {}; + return data[aKey] || ""; + } + + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + }, + + setCustomWindowValue(aWindow, aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setCustomWindowValue only accepts string values"); + } + + if (!("__SSi" in aWindow)) { + throw Components.Exception( + "Window is not tracked", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (!this._windows[aWindow.__SSi].extData) { + this._windows[aWindow.__SSi].extData = {}; + } + this._windows[aWindow.__SSi].extData[aKey] = aStringValue; + this.saveStateDelayed(aWindow); + }, + + deleteCustomWindowValue(aWindow, aKey) { + if ( + aWindow.__SSi && + this._windows[aWindow.__SSi].extData && + this._windows[aWindow.__SSi].extData[aKey] + ) { + delete this._windows[aWindow.__SSi].extData[aKey]; + } + this.saveStateDelayed(aWindow); + }, + + getCustomTabValue(aTab, aKey) { + return (TAB_CUSTOM_VALUES.get(aTab) || {})[aKey] || ""; + }, + + setCustomTabValue(aTab, aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setCustomTabValue only accepts string values"); + } + + // If the tab hasn't been restored, then set the data there, otherwise we + // could lose newly added data. + if (!TAB_CUSTOM_VALUES.has(aTab)) { + TAB_CUSTOM_VALUES.set(aTab, {}); + } + + TAB_CUSTOM_VALUES.get(aTab)[aKey] = aStringValue; + this.saveStateDelayed(aTab.ownerGlobal); + }, + + deleteCustomTabValue(aTab, aKey) { + let state = TAB_CUSTOM_VALUES.get(aTab); + if (state && aKey in state) { + delete state[aKey]; + this.saveStateDelayed(aTab.ownerGlobal); + } + }, + + /** + * Retrieves data specific to lazy-browser tabs. If tab is not lazy, + * will return undefined. + * + * @param aTab (xul:tab) + * The tabbrowser-tab the data is for. + * @param aKey (string) + * The key which maps to the desired data. + */ + getLazyTabValue(aTab, aKey) { + return (TAB_LAZY_STATES.get(aTab) || {})[aKey]; + }, + + getCustomGlobalValue(aKey) { + return this._globalState.get(aKey); + }, + + setCustomGlobalValue(aKey, aStringValue) { + if (typeof aStringValue != "string") { + throw new TypeError("setCustomGlobalValue only accepts string values"); + } + + this._globalState.set(aKey, aStringValue); + this.saveStateDelayed(); + }, + + deleteCustomGlobalValue(aKey) { + this._globalState.delete(aKey); + this.saveStateDelayed(); + }, + + persistTabAttribute: function ssi_persistTabAttribute(aName) { + if (lazy.TabAttributes.persist(aName)) { + this.saveStateDelayed(); + } + }, + + /** + * Undoes the closing of a tab or window which corresponds + * to the closedId passed in. + * + * @param aClosedId + * The closedId of the tab or window + * @param aIncludePrivate + * Whether to restore private tabs or windows + * + * @returns a tab or window object + */ + undoCloseById(aClosedId, aIncludePrivate = true) { + // Check for a window first. + for (let i = 0, l = this._closedWindows.length; i < l; i++) { + if (this._closedWindows[i].closedId == aClosedId) { + return this.undoCloseWindow(i); + } + } + + // Check for a tab. + for (let window of Services.wm.getEnumerator("navigator:browser")) { + if (!aIncludePrivate && PrivateBrowsingUtils.isWindowPrivate(window)) { + continue; + } + let windowState = this._windows[window.__SSi]; + if (windowState) { + for (let j = 0, l = windowState._closedTabs.length; j < l; j++) { + if (windowState._closedTabs[j].closedId == aClosedId) { + return this.undoCloseTab(window, j); + } + } + } + } + + // Neither a tab nor a window was found, return undefined and let the caller decide what to do about it. + return undefined; + }, + + /** + * Updates the label and icon for a <xul:tab> using the data from + * tabData. + * + * @param tab + * The <xul:tab> to update. + * @param tabData (optional) + * The tabData to use to update the tab. If the argument is + * not supplied, the data will be retrieved from the cache. + */ + updateTabLabelAndIcon(tab, tabData = null) { + if (tab.hasAttribute("customizemode")) { + return; + } + + let browser = tab.linkedBrowser; + let win = browser.ownerGlobal; + + if (!tabData) { + tabData = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + if (!tabData) { + throw new Error("tabData not found for given tab"); + } + } + + let activePageData = tabData.entries[tabData.index - 1] || null; + + // If the page has a title, set it. + if (activePageData) { + if (activePageData.title && activePageData.title != activePageData.url) { + win.gBrowser.setInitialTabTitle(tab, activePageData.title, { + isContentTitle: true, + }); + } else { + win.gBrowser.setInitialTabTitle(tab, activePageData.url); + } + } + + // Restore the tab icon. + if ("image" in tabData) { + // We know that about:blank is safe to load in any remote type. Since + // SessionStore is triggered with about:blank, there must be a process + // flip. We will ignore the first about:blank load to prevent resetting the + // favicon that we have set earlier to avoid flickering and improve + // perceived performance. + if ( + !activePageData || + (activePageData && activePageData.url != "about:blank") + ) { + win.gBrowser.setIcon( + tab, + tabData.image, + undefined, + tabData.iconLoadingPrincipal + ); + } + lazy.TabStateCache.update(browser.permanentKey, { + image: null, + iconLoadingPrincipal: null, + }); + } + }, + + // This method deletes all the closedTabs matching userContextId. + _forgetTabsWithUserContextId(userContextId) { + for (let window of Services.wm.getEnumerator("navigator:browser")) { + let windowState = this._windows[window.__SSi]; + if (windowState) { + // In order to remove the tabs in the correct order, we store the + // indexes, into an array, then we revert the array and remove closed + // data from the last one going backward. + let indexes = []; + windowState._closedTabs.forEach((closedTab, index) => { + if (closedTab.state.userContextId == userContextId) { + indexes.push(index); + } + }); + + for (let index of indexes.reverse()) { + this.removeClosedTabData(windowState, windowState._closedTabs, index); + } + } + } + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + /** + * Restores the session state stored in LastSession. This will attempt + * to merge data into the current session. If a window was opened at startup + * with pinned tab(s), then the remaining data from the previous session for + * that window will be opened into that window. Otherwise new windows will + * be opened. + */ + restoreLastSession: function ssi_restoreLastSession() { + // Use the public getter since it also checks PB mode + if (!this.canRestoreLastSession) { + throw Components.Exception("Last session can not be restored"); + } + + Services.obs.notifyObservers(null, NOTIFY_INITIATING_MANUAL_RESTORE); + + // First collect each window with its id... + let windows = {}; + for (let window of this._browserWindows) { + if (window.__SS_lastSessionWindowID) { + windows[window.__SS_lastSessionWindowID] = window; + } + } + + let lastSessionState = LastSession.getState(); + + // This shouldn't ever be the case... + if (!lastSessionState.windows.length) { + throw Components.Exception( + "lastSessionState has no windows", + Cr.NS_ERROR_UNEXPECTED + ); + } + + // We're technically doing a restore, so set things up so we send the + // notification when we're done. We want to send "sessionstore-browser-state-restored". + this._restoreCount = lastSessionState.windows.length; + this._browserSetState = true; + + // We want to re-use the last opened window instead of opening a new one in + // the case where it's "empty" and not associated with a window in the session. + // We will do more processing via _prepWindowToRestoreInto if we need to use + // the lastWindow. + let lastWindow = this._getTopWindow(); + let canUseLastWindow = lastWindow && !lastWindow.__SS_lastSessionWindowID; + + // global data must be restored before restoreWindow is called so that + // it happens before observers are notified + this._globalState.setFromState(lastSessionState); + + let openWindows = []; + let windowsToOpen = []; + + // Restore session cookies. + lazy.SessionCookies.restore(lastSessionState.cookies || []); + + // Restore into windows or open new ones as needed. + for (let i = 0; i < lastSessionState.windows.length; i++) { + let winState = lastSessionState.windows[i]; + let lastSessionWindowID = winState.__lastSessionWindowID; + // delete lastSessionWindowID so we don't add that to the window again + delete winState.__lastSessionWindowID; + + // See if we can use an open window. First try one that is associated with + // the state we're trying to restore and then fallback to the last selected + // window. + let windowToUse = windows[lastSessionWindowID]; + if (!windowToUse && canUseLastWindow) { + windowToUse = lastWindow; + canUseLastWindow = false; + } + + let [canUseWindow, canOverwriteTabs] = this._prepWindowToRestoreInto( + windowToUse + ); + + // If there's a window already open that we can restore into, use that + if (canUseWindow) { + // Since we're not overwriting existing tabs, we want to merge _closedTabs, + // putting existing ones first. Then make sure we're respecting the max pref. + if (winState._closedTabs && winState._closedTabs.length) { + let curWinState = this._windows[windowToUse.__SSi]; + curWinState._closedTabs = curWinState._closedTabs.concat( + winState._closedTabs + ); + curWinState._closedTabs.splice( + this._max_tabs_undo, + curWinState._closedTabs.length + ); + } + + // XXXzpao This is going to merge extData together (taking what was in + // winState over what is in the window already. + // We don't restore window right away, just store its data. + // Later, these windows will be restored with newly opened windows. + this._updateWindowRestoreState(windowToUse, { + windows: [winState], + options: { overwriteTabs: canOverwriteTabs }, + }); + openWindows.push(windowToUse); + } else { + windowsToOpen.push(winState); + } + } + + // Actually restore windows in reversed z-order. + this._openWindows({ windows: windowsToOpen }).then(openedWindows => + this._restoreWindowsInReversedZOrder(openWindows.concat(openedWindows)) + ); + + // Merge closed windows from this session with ones from last session + if (lastSessionState._closedWindows) { + this._closedWindows = this._closedWindows.concat( + lastSessionState._closedWindows + ); + this._capClosedWindows(); + this._closedObjectsChanged = true; + } + + lazy.DevToolsShim.restoreDevToolsSession(lastSessionState); + + // Set data that persists between sessions + this._recentCrashes = + (lastSessionState.session && lastSessionState.session.recentCrashes) || 0; + + // Update the session start time using the restored session state. + this._updateSessionStartTime(lastSessionState); + + LastSession.clear(); + + // Notify of changes to closed objects. + this._notifyOfClosedObjectsChange(); + }, + + /** + * Revive a crashed tab and restore its state from before it crashed. + * + * @param aTab + * A <xul:tab> linked to a crashed browser. This is a no-op if the + * browser hasn't actually crashed, or is not associated with a tab. + * This function will also throw if the browser happens to be remote. + */ + reviveCrashedTab(aTab) { + if (!aTab) { + throw new Error( + "SessionStore.reviveCrashedTab expected a tab, but got null." + ); + } + + const gBrowser = aTab.ownerGlobal.gBrowser; + let browser = aTab.linkedBrowser; + if (!this._crashedBrowsers.has(browser.permanentKey)) { + return; + } + + // Sanity check - the browser to be revived should not be remote + // at this point. + if (browser.isRemoteBrowser) { + throw new Error( + "SessionStore.reviveCrashedTab: " + + "Somehow a crashed browser is still remote." + ); + } + + // We put the browser at about:blank in case the user is + // restoring tabs on demand. This way, the user won't see + // a flash of the about:tabcrashed page after selecting + // the revived tab. + aTab.removeAttribute("crashed"); + gBrowser.tabContainer.updateTabIndicatorAttr(aTab); + + browser.loadURI("about:blank", { + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({ + userContextId: aTab.userContextId, + }), + remoteTypeOverride: lazy.E10SUtils.NOT_REMOTE, + }); + + let data = lazy.TabState.collect(aTab, TAB_CUSTOM_VALUES.get(aTab)); + this.restoreTab(aTab, data, { + forceOnDemand: true, + }); + }, + + /** + * Revive all crashed tabs and reset the crashed tabs count to 0. + */ + reviveAllCrashedTabs() { + for (let window of Services.wm.getEnumerator("navigator:browser")) { + for (let tab of window.gBrowser.tabs) { + this.reviveCrashedTab(tab); + } + } + }, + + /** + * Retrieves the latest session history information for a tab. The cached data + * is returned immediately, but a callback may be provided that supplies + * up-to-date data when or if it is available. The callback is passed a single + * argument with data in the same format as the return value. + * + * @param tab tab to retrieve the session history for + * @param updatedCallback function to call with updated data as the single argument + * @returns a object containing 'index' specifying the current index, and an + * array 'entries' containing an object for each history item. + */ + getSessionHistory(tab, updatedCallback) { + if (updatedCallback) { + lazy.TabStateFlusher.flush(tab.linkedBrowser).then(() => { + let sessionHistory = this.getSessionHistory(tab); + if (sessionHistory) { + updatedCallback(sessionHistory); + } + }); + } + + // Don't continue if the tab was closed before TabStateFlusher.flush resolves. + if (tab.linkedBrowser) { + let tabState = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + return { index: tabState.index - 1, entries: tabState.entries }; + } + return null; + }, + + /** + * See if aWindow is usable for use when restoring a previous session via + * restoreLastSession. If usable, prepare it for use. + * + * @param aWindow + * the window to inspect & prepare + * @returns [canUseWindow, canOverwriteTabs] + * canUseWindow: can the window be used to restore into + * canOverwriteTabs: all of the current tabs are home pages and we + * can overwrite them + */ + _prepWindowToRestoreInto: function ssi_prepWindowToRestoreInto(aWindow) { + if (!aWindow) { + return [false, false]; + } + + // We might be able to overwrite the existing tabs instead of just adding + // the previous session's tabs to the end. This will be set if possible. + let canOverwriteTabs = false; + + // Look at the open tabs in comparison to home pages. If all the tabs are + // home pages then we'll end up overwriting all of them. Otherwise we'll + // just close the tabs that match home pages. Tabs with the about:blank + // URI will always be overwritten. + let homePages = ["about:blank"]; + let removableTabs = []; + let tabbrowser = aWindow.gBrowser; + let startupPref = this._prefBranch.getIntPref("startup.page"); + if (startupPref == 1) { + homePages = homePages.concat(lazy.HomePage.get(aWindow).split("|")); + } + + for (let i = tabbrowser._numPinnedTabs; i < tabbrowser.tabs.length; i++) { + let tab = tabbrowser.tabs[i]; + if (homePages.includes(tab.linkedBrowser.currentURI.spec)) { + removableTabs.push(tab); + } + } + + if ( + tabbrowser.tabs.length > tabbrowser.visibleTabs.length && + tabbrowser.visibleTabs.length === removableTabs.length + ) { + // If all the visible tabs are also removable and the selected tab is hidden or removeable, we will later remove + // all "removable" tabs causing the browser to automatically close because the only tab left is hidden. + // To prevent the browser from automatically closing, we will leave one other visible tab open. + removableTabs.shift(); + } + + if (tabbrowser.tabs.length == removableTabs.length) { + canOverwriteTabs = true; + } else { + // If we're not overwriting all of the tabs, then close the home tabs. + for (let i = removableTabs.length - 1; i >= 0; i--) { + tabbrowser.removeTab(removableTabs.pop(), { animate: false }); + } + } + + return [true, canOverwriteTabs]; + }, + + /* ........ Saving Functionality .............. */ + + /** + * Store window dimensions, visibility, sidebar + * @param aWindow + * Window reference + */ + _updateWindowFeatures: function ssi_updateWindowFeatures(aWindow) { + var winData = this._windows[aWindow.__SSi]; + + WINDOW_ATTRIBUTES.forEach(function(aAttr) { + winData[aAttr] = this._getWindowDimension(aWindow, aAttr); + }, this); + + if (winData.sizemode != "minimized") { + winData.sizemodeBeforeMinimized = winData.sizemode; + } + + var hidden = WINDOW_HIDEABLE_FEATURES.filter(function(aItem) { + return aWindow[aItem] && !aWindow[aItem].visible; + }); + if (hidden.length) { + winData.hidden = hidden.join(","); + } else if (winData.hidden) { + delete winData.hidden; + } + + let sidebarBox = aWindow.document.getElementById("sidebar-box"); + let sidebar = sidebarBox.getAttribute("sidebarcommand"); + if (sidebar && sidebarBox.getAttribute("checked") == "true") { + winData.sidebar = sidebar; + } else if (winData.sidebar) { + delete winData.sidebar; + } + let workspaceID = aWindow.getWorkspaceID(); + if (workspaceID) { + winData.workspaceID = workspaceID; + } + }, + + /** + * gather session data as object + * @param aUpdateAll + * Bool update all windows + * @returns object + */ + getCurrentState(aUpdateAll) { + this._handleClosedWindows().then(() => { + this._notifyOfClosedObjectsChange(); + }); + + var activeWindow = this._getTopWindow(); + + TelemetryStopwatch.start("FX_SESSION_RESTORE_COLLECT_ALL_WINDOWS_DATA_MS"); + if (lazy.RunState.isRunning) { + // update the data for all windows with activities since the last save operation. + let index = 0; + for (let window of this._orderedBrowserWindows) { + if (!this._isWindowLoaded(window)) { + // window data is still in _statesToRestore + continue; + } + if (aUpdateAll || DirtyWindows.has(window) || window == activeWindow) { + this._collectWindowData(window); + } else { + // always update the window features (whose change alone never triggers a save operation) + this._updateWindowFeatures(window); + } + this._windows[window.__SSi].zIndex = ++index; + } + DirtyWindows.clear(); + } + TelemetryStopwatch.finish("FX_SESSION_RESTORE_COLLECT_ALL_WINDOWS_DATA_MS"); + + // An array that at the end will hold all current window data. + var total = []; + // The ids of all windows contained in 'total' in the same order. + var ids = []; + // The number of window that are _not_ popups. + var nonPopupCount = 0; + var ix; + + // collect the data for all windows + for (ix in this._windows) { + if (this._windows[ix]._restoring) { + // window data is still in _statesToRestore + continue; + } + total.push(this._windows[ix]); + ids.push(ix); + if (!this._windows[ix].isPopup) { + nonPopupCount++; + } + } + + // collect the data for all windows yet to be restored + for (ix in this._statesToRestore) { + for (let winData of this._statesToRestore[ix].windows) { + total.push(winData); + if (!winData.isPopup) { + nonPopupCount++; + } + } + } + + // shallow copy this._closedWindows to preserve current state + let lastClosedWindowsCopy = this._closedWindows.slice(); + + if (AppConstants.platform != "macosx") { + // If no non-popup browser window remains open, return the state of the last + // closed window(s). We only want to do this when we're actually "ending" + // the session. + // XXXzpao We should do this for _restoreLastWindow == true, but that has + // its own check for popups. c.f. bug 597619 + if ( + nonPopupCount == 0 && + !!lastClosedWindowsCopy.length && + lazy.RunState.isQuitting + ) { + // prepend the last non-popup browser window, so that if the user loads more tabs + // at startup we don't accidentally add them to a popup window + do { + total.unshift(lastClosedWindowsCopy.shift()); + } while (total[0].isPopup && lastClosedWindowsCopy.length); + } + } + + if (activeWindow) { + this.activeWindowSSiCache = activeWindow.__SSi || ""; + } + ix = ids.indexOf(this.activeWindowSSiCache); + // We don't want to restore focus to a minimized window or a window which had all its + // tabs stripped out (doesn't exist). + if (ix != -1 && total[ix] && total[ix].sizemode == "minimized") { + ix = -1; + } + + let session = { + lastUpdate: Date.now(), + startTime: this._sessionStartTime, + recentCrashes: this._recentCrashes, + }; + + let state = { + version: ["sessionrestore", FORMAT_VERSION], + windows: total, + selectedWindow: ix + 1, + _closedWindows: lastClosedWindowsCopy, + session, + global: this._globalState.getState(), + }; + + // Collect and store session cookies. + state.cookies = lazy.SessionCookies.collect(); + + lazy.DevToolsShim.saveDevToolsSession(state); + + // Persist the last session if we deferred restoring it + if (LastSession.canRestore) { + state.lastSessionState = LastSession.getState(); + } + + // If we were called by the SessionSaver and started with only a private + // window we want to pass the deferred initial state to not lose the + // previous session. + if (this._deferredInitialState) { + state.deferredInitialState = this._deferredInitialState; + } + + return state; + }, + + /** + * serialize session data for a window + * @param aWindow + * Window reference + * @returns string + */ + _getWindowState: function ssi_getWindowState(aWindow) { + if (!this._isWindowLoaded(aWindow)) { + return this._statesToRestore[WINDOW_RESTORE_IDS.get(aWindow)]; + } + + if (lazy.RunState.isRunning) { + this._collectWindowData(aWindow); + } + + return { windows: [this._windows[aWindow.__SSi]] }; + }, + + /** + * Gathers data about a window and its tabs, and updates its + * entry in this._windows. + * + * @param aWindow + * Window references. + * @returns a Map mapping the browser tabs from aWindow to the tab + * entry that was put into the window data in this._windows. + */ + _collectWindowData: function ssi_collectWindowData(aWindow) { + let tabMap = new Map(); + + if (!this._isWindowLoaded(aWindow)) { + return tabMap; + } + + let tabbrowser = aWindow.gBrowser; + let tabs = tabbrowser.tabs; + let winData = this._windows[aWindow.__SSi]; + let tabsData = (winData.tabs = []); + + // update the internal state data for this window + for (let tab of tabs) { + if (tab == aWindow.FirefoxViewHandler.tab) { + continue; + } + let tabData = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + tabMap.set(tab, tabData); + tabsData.push(tabData); + } + + let selectedIndex = tabbrowser.tabbox.selectedIndex + 1; + // We don't store the Firefox View tab in Session Store, so if it was the last selected "tab" when + // a window is closed, point to the first item in the tab strip instead (it will never be the Firefox View tab, + // since it's only inserted into the tab strip after it's selected). + if (aWindow.FirefoxViewHandler.tab?.selected) { + selectedIndex = 1; + winData.title = tabbrowser.tabs[0].label; + } + winData.selected = selectedIndex; + + this._updateWindowFeatures(aWindow); + + // Make sure we keep __SS_lastSessionWindowID around for cases like entering + // or leaving PB mode. + if (aWindow.__SS_lastSessionWindowID) { + this._windows[aWindow.__SSi].__lastSessionWindowID = + aWindow.__SS_lastSessionWindowID; + } + + DirtyWindows.remove(aWindow); + return tabMap; + }, + + /* ........ Restoring Functionality .............. */ + + /** + * Open windows with data + * + * @param root + * Windows data + * @returns a promise resolved when all windows have been opened + */ + _openWindows(root) { + let windowsOpened = []; + for (let winData of root.windows) { + if (!winData || !winData.tabs || !winData.tabs[0]) { + continue; + } + windowsOpened.push(this._openWindowWithState({ windows: [winData] })); + } + let windowOpenedPromises = []; + for (const openedWindow of windowsOpened) { + let deferred = WINDOW_SHOWING_PROMISES.get(openedWindow); + windowOpenedPromises.push(deferred.promise); + } + return Promise.all(windowOpenedPromises); + }, + + /** reset closedId's from previous sessions to ensure these IDs are unique + * @param tabData + * an array of data to be restored + * @returns the updated tabData array + */ + _resetClosedIds(tabData) { + for (let entry of tabData) { + entry.closedId = this._nextClosedId++; + } + return tabData; + }, + /** + * restore features to a single window + * @param aWindow + * Window reference to the window to use for restoration + * @param winData + * JS object + * @param aOptions + * {overwriteTabs: true} to overwrite existing tabs w/ new ones + * {firstWindow: true} if this is the first non-private window we're + * restoring in this session, that might open an + * external link as well + */ + restoreWindow: function ssi_restoreWindow(aWindow, winData, aOptions = {}) { + let overwriteTabs = aOptions && aOptions.overwriteTabs; + let firstWindow = aOptions && aOptions.firstWindow; + + // initialize window if necessary + if (aWindow && (!aWindow.__SSi || !this._windows[aWindow.__SSi])) { + this.onLoad(aWindow); + } + + TelemetryStopwatch.start("FX_SESSION_RESTORE_RESTORE_WINDOW_MS"); + + // We're not returning from this before we end up calling restoreTabs + // for this window, so make sure we send the SSWindowStateBusy event. + this._sendWindowRestoringNotification(aWindow); + this._setWindowStateBusy(aWindow); + + if (winData.workspaceID) { + aWindow.moveToWorkspace(winData.workspaceID); + } + + if (!winData.tabs) { + winData.tabs = []; + // don't restore a single blank tab when we've had an external + // URL passed in for loading at startup (cf. bug 357419) + } else if ( + firstWindow && + !overwriteTabs && + winData.tabs.length == 1 && + (!winData.tabs[0].entries || !winData.tabs[0].entries.length) + ) { + winData.tabs = []; + } + + // See SessionStoreInternal.restoreTabs for a description of what + // selectTab represents. + let selectTab = 0; + if (overwriteTabs) { + selectTab = parseInt(winData.selected || 1, 10); + selectTab = Math.max(selectTab, 1); + selectTab = Math.min(selectTab, winData.tabs.length); + } + + let tabbrowser = aWindow.gBrowser; + + // disable smooth scrolling while adding, moving, removing and selecting tabs + let arrowScrollbox = tabbrowser.tabContainer.arrowScrollbox; + let smoothScroll = arrowScrollbox.smoothScroll; + arrowScrollbox.smoothScroll = false; + + // We need to keep track of the initially open tabs so that they + // can be moved to the end of the restored tabs. + let initialTabs; + if (!overwriteTabs && firstWindow) { + initialTabs = Array.from(tabbrowser.tabs); + } + + // Get rid of tabs that aren't needed anymore. + if (overwriteTabs) { + for (let i = tabbrowser.browsers.length - 1; i >= 0; i--) { + if (!tabbrowser.tabs[i].selected) { + tabbrowser.removeTab(tabbrowser.tabs[i]); + } + } + } + + let restoreTabsLazily = + this._prefBranch.getBoolPref("sessionstore.restore_tabs_lazily") && + this._restore_on_demand; + + if (winData.tabs.length) { + var tabs = tabbrowser.addMultipleTabs( + restoreTabsLazily, + selectTab, + winData.tabs + ); + } + + // Move the originally open tabs to the end. + if (initialTabs) { + let endPosition = tabbrowser.tabs.length - 1; + for (let i = 0; i < initialTabs.length; i++) { + tabbrowser.unpinTab(initialTabs[i]); + tabbrowser.moveTabTo(initialTabs[i], endPosition); + } + } + + // We want to correlate the window with data from the last session, so + // assign another id if we have one. Otherwise clear so we don't do + // anything with it. + delete aWindow.__SS_lastSessionWindowID; + if (winData.__lastSessionWindowID) { + aWindow.__SS_lastSessionWindowID = winData.__lastSessionWindowID; + } + + if (overwriteTabs) { + delete this._windows[aWindow.__SSi].extData; + } + + // Restore cookies from legacy sessions, i.e. before bug 912717. + lazy.SessionCookies.restore(winData.cookies || []); + + if (winData.extData) { + if (!this._windows[aWindow.__SSi].extData) { + this._windows[aWindow.__SSi].extData = {}; + } + for (var key in winData.extData) { + this._windows[aWindow.__SSi].extData[key] = winData.extData[key]; + } + } + + let newClosedTabsData = winData._closedTabs || []; + newClosedTabsData = this._resetClosedIds(newClosedTabsData); + + let newLastClosedTabGroupCount = winData._lastClosedTabGroupCount || -1; + + if (overwriteTabs || firstWindow) { + // Overwrite existing closed tabs data when overwriteTabs=true + // or we're the first window to be restored. + this._windows[aWindow.__SSi]._closedTabs = newClosedTabsData; + } else if (this._max_tabs_undo > 0) { + // If we merge tabs, we also want to merge closed tabs data. We'll assume + // the restored tabs were closed more recently and append the current list + // of closed tabs to the new one... + newClosedTabsData = newClosedTabsData.concat( + this._windows[aWindow.__SSi]._closedTabs + ); + + // ... and make sure that we don't exceed the max number of closed tabs + // we can restore. + this._windows[aWindow.__SSi]._closedTabs = newClosedTabsData.slice( + 0, + this._max_tabs_undo + ); + } + // Because newClosedTabsData are put in first, we need to + // copy also the _lastClosedTabGroupCount. + this._windows[ + aWindow.__SSi + ]._lastClosedTabGroupCount = newLastClosedTabGroupCount; + + if (!this._isWindowLoaded(aWindow)) { + // from now on, the data will come from the actual window + delete this._statesToRestore[WINDOW_RESTORE_IDS.get(aWindow)]; + WINDOW_RESTORE_IDS.delete(aWindow); + delete this._windows[aWindow.__SSi]._restoring; + } + + // Restore tabs, if any. + if (winData.tabs.length) { + this.restoreTabs(aWindow, tabs, winData.tabs, selectTab); + } + + // set smoothScroll back to the original value + arrowScrollbox.smoothScroll = smoothScroll; + + TelemetryStopwatch.finish("FX_SESSION_RESTORE_RESTORE_WINDOW_MS"); + + this._setWindowStateReady(aWindow); + + this._sendWindowRestoredNotification(aWindow); + + Services.obs.notifyObservers(aWindow, NOTIFY_SINGLE_WINDOW_RESTORED); + + this._sendRestoreCompletedNotifications(); + }, + + /** + * Prepare connection to host beforehand. + * + * @param tab + * Tab we are loading from. + * @param url + * URL of a host. + * @returns a flag indicates whether a connection has been made + */ + prepareConnectionToHost(tab, url) { + if (url && !url.startsWith("about:")) { + let principal = Services.scriptSecurityManager.createNullPrincipal({ + userContextId: tab.userContextId, + }); + let sc = Services.io.QueryInterface(Ci.nsISpeculativeConnect); + let uri = Services.io.newURI(url); + try { + sc.speculativeConnect(uri, principal, null); + return true; + } catch (error) { + // Can't setup speculative connection for this url. + Cu.reportError(error); + return false; + } + } + return false; + }, + + /** + * Make a connection to a host when users hover mouse on a tab. + * This will also set a flag in the tab to prevent us from speculatively + * connecting a second time. + * + * @param tab + * a tab to speculatively connect on mouse hover. + */ + speculativeConnectOnTabHover(tab) { + let tabState = TAB_LAZY_STATES.get(tab); + if (tabState && !tabState.connectionPrepared) { + let url = this.getLazyTabValue(tab, "url"); + let prepared = this.prepareConnectionToHost(tab, url); + // This is used to test if a connection has been made beforehand. + if (gDebuggingEnabled) { + tab.__test_connection_prepared = prepared; + tab.__test_connection_url = url; + } + // A flag indicate that we've prepared a connection for this tab and + // if is called again, we shouldn't prepare another connection. + tabState.connectionPrepared = true; + } + }, + + /** + * This function will restore window features and then retore window data. + * + * @param windows + * ordered array of windows to restore + */ + _restoreWindowsFeaturesAndTabs(windows) { + // First, we restore window features, so that when users start interacting + // with a window, we don't steal the window focus. + for (let window of windows) { + let state = this._statesToRestore[WINDOW_RESTORE_IDS.get(window)]; + this.restoreWindowFeatures(window, state.windows[0]); + } + + // Then we restore data into windows. + for (let window of windows) { + let state = this._statesToRestore[WINDOW_RESTORE_IDS.get(window)]; + this.restoreWindow( + window, + state.windows[0], + state.options || { overwriteTabs: true } + ); + WINDOW_RESTORE_ZINDICES.delete(window); + } + }, + + /** + * This function will restore window in reversed z-index, so that users will + * be presented with most recently used window first. + * + * @param windows + * unordered array of windows to restore + */ + _restoreWindowsInReversedZOrder(windows) { + windows.sort( + (a, b) => + (WINDOW_RESTORE_ZINDICES.get(a) || 0) - + (WINDOW_RESTORE_ZINDICES.get(b) || 0) + ); + + this.windowToFocus = windows[0]; + this._restoreWindowsFeaturesAndTabs(windows); + }, + + /** + * Restore multiple windows using the provided state. + * @param aWindow + * Window reference to the first window to use for restoration. + * Additionally required windows will be opened. + * @param aState + * JS object or JSON string + * @param aOptions + * {overwriteTabs: true} to overwrite existing tabs w/ new ones + * {firstWindow: true} if this is the first non-private window we're + * restoring in this session, that might open an + * external link as well + */ + restoreWindows: function ssi_restoreWindows(aWindow, aState, aOptions = {}) { + // initialize window if necessary + if (aWindow && (!aWindow.__SSi || !this._windows[aWindow.__SSi])) { + this.onLoad(aWindow); + } + + let root; + try { + root = typeof aState == "string" ? JSON.parse(aState) : aState; + } catch (ex) { + // invalid state object - don't restore anything + this._log.error(ex); + this._sendRestoreCompletedNotifications(); + return; + } + + // Restore closed windows if any. + if (root._closedWindows) { + this._closedWindows = root._closedWindows; + this._closedObjectsChanged = true; + } + + // We're done here if there are no windows. + if (!root.windows || !root.windows.length) { + this._sendRestoreCompletedNotifications(); + return; + } + + let firstWindowData = root.windows.splice(0, 1); + // Store the restore state and restore option of the current window, + // so that the window can be restored in reversed z-order. + this._updateWindowRestoreState(aWindow, { + windows: firstWindowData, + options: aOptions, + }); + + // Begin the restoration: First open all windows in creation order. After all + // windows have opened, we restore states to windows in reversed z-order. + this._openWindows(root).then(windows => { + // We want to add current window to opened window, so that this window will be + // restored in reversed z-order. (We add the window to first position, in case + // no z-indices are found, that window will be restored first.) + windows.unshift(aWindow); + + this._restoreWindowsInReversedZOrder(windows); + }); + + lazy.DevToolsShim.restoreDevToolsSession(aState); + }, + + /** + * Manage history restoration for a window + * @param aWindow + * Window to restore the tabs into + * @param aTabs + * Array of tab references + * @param aTabData + * Array of tab data + * @param aSelectTab + * Index of the tab to select. This is a 1-based index where "1" + * indicates the first tab should be selected, and "0" indicates that + * the currently selected tab will not be changed. + */ + restoreTabs(aWindow, aTabs, aTabData, aSelectTab) { + var tabbrowser = aWindow.gBrowser; + + let numTabsToRestore = aTabs.length; + let numTabsInWindow = tabbrowser.tabs.length; + let tabsDataArray = this._windows[aWindow.__SSi].tabs; + + // Update the window state in case we shut down without being notified. + // Individual tab states will be taken care of by restoreTab() below. + if (numTabsInWindow == numTabsToRestore) { + // Remove all previous tab data. + tabsDataArray.length = 0; + } else { + // Remove all previous tab data except tabs that should not be overriden. + tabsDataArray.splice(numTabsInWindow - numTabsToRestore); + } + + // Let the tab data array have the right number of slots. + tabsDataArray.length = numTabsInWindow; + + if (aSelectTab > 0 && aSelectTab <= aTabs.length) { + // Update the window state in case we shut down without being notified. + this._windows[aWindow.__SSi].selected = aSelectTab; + } + + // If we restore the selected tab, make sure it goes first. + let selectedIndex = aTabs.indexOf(tabbrowser.selectedTab); + if (selectedIndex > -1) { + this.restoreTab(tabbrowser.selectedTab, aTabData[selectedIndex]); + } + + // Restore all tabs. + for (let t = 0; t < aTabs.length; t++) { + if (t != selectedIndex) { + this.restoreTab(aTabs[t], aTabData[t]); + } + } + }, + + // Restores the given tab state for a given tab. + restoreTab(tab, tabData, options = {}) { + let browser = tab.linkedBrowser; + + if (TAB_STATE_FOR_BROWSER.has(browser)) { + Cu.reportError("Must reset tab before calling restoreTab."); + return; + } + + let loadArguments = options.loadArguments; + let window = tab.ownerGlobal; + let tabbrowser = window.gBrowser; + let forceOnDemand = options.forceOnDemand; + let isRemotenessUpdate = options.isRemotenessUpdate; + + let willRestoreImmediately = + options.restoreImmediately || tabbrowser.selectedBrowser == browser; + + let isBrowserInserted = browser.isConnected; + + // Increase the busy state counter before modifying the tab. + this._setWindowStateBusy(window); + + // It's important to set the window state to dirty so that + // we collect their data for the first time when saving state. + DirtyWindows.add(window); + + // In case we didn't collect/receive data for any tabs yet we'll have to + // fill the array with at least empty tabData objects until |_tPos| or + // we'll end up with |null| entries. + for (let otherTab of Array.prototype.slice.call( + tabbrowser.tabs, + 0, + tab._tPos + )) { + let emptyState = { entries: [], lastAccessed: otherTab.lastAccessed }; + this._windows[window.__SSi].tabs.push(emptyState); + } + + // Update the tab state in case we shut down without being notified. + this._windows[window.__SSi].tabs[tab._tPos] = tabData; + + // Prepare the tab so that it can be properly restored. We'll also attach + // a copy of the tab's data in case we close it before it's been restored. + // Anything that dispatches an event to external consumers must happen at + // the end of this method, to make sure that the tab/browser object is in a + // reliable and consistent state. + + if (tabData.lastAccessed) { + tab.updateLastAccessed(tabData.lastAccessed); + } + + if ("attributes" in tabData) { + // Ensure that we persist tab attributes restored from previous sessions. + Object.keys(tabData.attributes).forEach(a => + lazy.TabAttributes.persist(a) + ); + } + + if (!tabData.entries) { + tabData.entries = []; + } + if (tabData.extData) { + TAB_CUSTOM_VALUES.set(tab, Cu.cloneInto(tabData.extData, {})); + } else { + TAB_CUSTOM_VALUES.delete(tab); + } + + // Tab is now open. + delete tabData.closedAt; + + // Ensure the index is in bounds. + let activeIndex = (tabData.index || tabData.entries.length) - 1; + activeIndex = Math.min(activeIndex, tabData.entries.length - 1); + activeIndex = Math.max(activeIndex, 0); + + // Save the index in case we updated it above. + tabData.index = activeIndex + 1; + + tab.setAttribute("pending", "true"); + + // If we're restoring this tab, it certainly shouldn't be in + // the ignored set anymore. + this._crashedBrowsers.delete(browser.permanentKey); + + // If we're in the midst of performing a process flip, then we must + // have initiated a navigation. This means that these userTyped* + // values are now out of date. + if ( + options.restoreContentReason == + RESTORE_TAB_CONTENT_REASON.NAVIGATE_AND_RESTORE + ) { + delete tabData.userTypedValue; + delete tabData.userTypedClear; + } + + // Update the persistent tab state cache with |tabData| information. + lazy.TabStateCache.update(browser.permanentKey, { + // NOTE: Copy the entries array shallowly, so as to not screw with the + // original tabData's history when getting history updates. + history: { entries: [...tabData.entries], index: tabData.index }, + scroll: tabData.scroll || null, + storage: tabData.storage || null, + formdata: tabData.formdata || null, + disallow: tabData.disallow || null, + userContextId: tabData.userContextId || 0, + + // This information is only needed until the tab has finished restoring. + // When that's done it will be removed from the cache and we always + // collect it in TabState._collectBaseTabData(). + image: tabData.image || "", + iconLoadingPrincipal: tabData.iconLoadingPrincipal || null, + searchMode: tabData.searchMode || null, + userTypedValue: tabData.userTypedValue || "", + userTypedClear: tabData.userTypedClear || 0, + }); + + // Restore tab attributes. + if ("attributes" in tabData) { + lazy.TabAttributes.set(tab, tabData.attributes); + } + + if (isBrowserInserted) { + // Start a new epoch to discard all frame script messages relating to a + // previous epoch. All async messages that are still on their way to chrome + // will be ignored and don't override any tab data set when restoring. + let epoch = this.startNextEpoch(browser.permanentKey); + + // Ensure that the tab will get properly restored in the event the tab + // crashes while restoring. But don't set this on lazy browsers as + // restoreTab will get called again when the browser is instantiated. + TAB_STATE_FOR_BROWSER.set(browser, TAB_STATE_NEEDS_RESTORE); + + this._sendRestoreHistory(browser, { + tabData, + epoch, + loadArguments, + isRemotenessUpdate, + }); + + // This could cause us to ignore MAX_CONCURRENT_TAB_RESTORES a bit, but + // it ensures each window will have its selected tab loaded. + if (willRestoreImmediately) { + this.restoreTabContent(tab, options); + } else if (!forceOnDemand) { + TabRestoreQueue.add(tab); + // Check if a tab is in queue and will be restored + // after the currently loading tabs. If so, prepare + // a connection to host to speed up page loading. + if (TabRestoreQueue.willRestoreSoon(tab)) { + if (activeIndex in tabData.entries) { + let url = tabData.entries[activeIndex].url; + let prepared = this.prepareConnectionToHost(tab, url); + if (gDebuggingEnabled) { + tab.__test_connection_prepared = prepared; + tab.__test_connection_url = url; + } + } + } + this.restoreNextTab(); + } + } else { + // TAB_LAZY_STATES holds data for lazy-browser tabs to proxy for + // data unobtainable from the unbound browser. This only applies to lazy + // browsers and will be removed once the browser is inserted in the document. + // This must preceed `updateTabLabelAndIcon` call for required data to be present. + let url = "about:blank"; + let title = ""; + + if (activeIndex in tabData.entries) { + url = tabData.entries[activeIndex].url; + title = tabData.entries[activeIndex].title || url; + } + TAB_LAZY_STATES.set(tab, { + url, + title, + userTypedValue: tabData.userTypedValue || "", + userTypedClear: tabData.userTypedClear || 0, + }); + } + + // Most of tabData has been restored, now continue with restoring + // attributes that may trigger external events. + + if (tabData.pinned) { + tabbrowser.pinTab(tab); + } else { + tabbrowser.unpinTab(tab); + } + + if (tabData.hidden) { + tabbrowser.hideTab(tab); + } else { + tabbrowser.showTab(tab); + } + + if (!!tabData.muted != browser.audioMuted) { + tab.toggleMuteAudio(tabData.muteReason); + } + + if (tab.hasAttribute("customizemode")) { + window.gCustomizeMode.setTab(tab); + } + + // Update tab label and icon to show something + // while we wait for the messages to be processed. + this.updateTabLabelAndIcon(tab, tabData); + + // Decrease the busy state counter after we're done. + this._setWindowStateReady(window); + }, + + /** + * Kicks off restoring the given tab. + * + * @param aTab + * the tab to restore + * @param aOptions + * optional arguments used when performing process switch during load + */ + restoreTabContent(aTab, aOptions = {}) { + let loadArguments = aOptions.loadArguments; + if (aTab.hasAttribute("customizemode") && !loadArguments) { + return; + } + + let browser = aTab.linkedBrowser; + let window = aTab.ownerGlobal; + let tabbrowser = window.gBrowser; + let tabData = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); + let activeIndex = tabData.index - 1; + let activePageData = tabData.entries[activeIndex] || null; + let uri = activePageData ? activePageData.url || null : null; + + this.markTabAsRestoring(aTab); + + let isRemotenessUpdate = aOptions.isRemotenessUpdate; + let explicitlyUpdateRemoteness = !Services.appinfo.sessionHistoryInParent; + // If we aren't already updating the browser's remoteness, check if it's + // necessary. + if (explicitlyUpdateRemoteness && !isRemotenessUpdate) { + isRemotenessUpdate = tabbrowser.updateBrowserRemotenessByURL( + browser, + uri + ); + + if (isRemotenessUpdate) { + // We updated the remoteness, so we need to send the history down again. + // + // Start a new epoch to discard all frame script messages relating to a + // previous epoch. All async messages that are still on their way to chrome + // will be ignored and don't override any tab data set when restoring. + let epoch = this.startNextEpoch(browser.permanentKey); + + this._sendRestoreHistory(browser, { + tabData, + epoch, + loadArguments, + isRemotenessUpdate, + }); + } + } + + this._sendRestoreTabContent(browser, { + loadArguments, + isRemotenessUpdate, + reason: + aOptions.restoreContentReason || RESTORE_TAB_CONTENT_REASON.SET_STATE, + }); + + // Focus the tab's content area, unless the restore is for a new tab URL or + // was triggered by a DocumentChannel process switch. + if ( + aTab.selected && + !window.isBlankPageURL(uri) && + !aOptions.isRemotenessUpdate + ) { + browser.focus(); + } + }, + + /** + * Marks a given pending tab as restoring. + * + * @param aTab + * the pending tab to mark as restoring + */ + markTabAsRestoring(aTab) { + let browser = aTab.linkedBrowser; + if (TAB_STATE_FOR_BROWSER.get(browser) != TAB_STATE_NEEDS_RESTORE) { + throw new Error("Given tab is not pending."); + } + + // Make sure that this tab is removed from the priority queue. + TabRestoreQueue.remove(aTab); + + // Increase our internal count. + this._tabsRestoringCount++; + + // Set this tab's state to restoring + TAB_STATE_FOR_BROWSER.set(browser, TAB_STATE_RESTORING); + aTab.removeAttribute("pending"); + }, + + /** + * This _attempts_ to restore the next available tab. If the restore fails, + * then we will attempt the next one. + * There are conditions where this won't do anything: + * if we're in the process of quitting + * if there are no tabs to restore + * if we have already reached the limit for number of tabs to restore + */ + restoreNextTab: function ssi_restoreNextTab() { + // If we call in here while quitting, we don't actually want to do anything + if (lazy.RunState.isQuitting) { + return; + } + + // Don't exceed the maximum number of concurrent tab restores. + if (this._tabsRestoringCount >= MAX_CONCURRENT_TAB_RESTORES) { + return; + } + + let tab = TabRestoreQueue.shift(); + if (tab) { + this.restoreTabContent(tab); + } + }, + + /** + * Restore visibility and dimension features to a window + * @param aWindow + * Window reference + * @param aWinData + * Object containing session data for the window + */ + restoreWindowFeatures: function ssi_restoreWindowFeatures(aWindow, aWinData) { + var hidden = aWinData.hidden ? aWinData.hidden.split(",") : []; + WINDOW_HIDEABLE_FEATURES.forEach(function(aItem) { + aWindow[aItem].visible = !hidden.includes(aItem); + }); + + if (aWinData.isPopup) { + this._windows[aWindow.__SSi].isPopup = true; + if (aWindow.gURLBar) { + aWindow.gURLBar.readOnly = true; + } + } else { + delete this._windows[aWindow.__SSi].isPopup; + if (aWindow.gURLBar) { + aWindow.gURLBar.readOnly = false; + } + } + + aWindow.setTimeout(() => { + this.restoreDimensions( + aWindow, + +(aWinData.width || 0), + +(aWinData.height || 0), + "screenX" in aWinData ? +aWinData.screenX : NaN, + "screenY" in aWinData ? +aWinData.screenY : NaN, + aWinData.sizemode || "", + aWinData.sizemodeBeforeMinimized || "", + aWinData.sidebar || "" + ); + }, 0); + }, + + /** + * Restore a window's dimensions + * @param aWidth + * Window width in desktop pixels + * @param aHeight + * Window height in desktop pixels + * @param aLeft + * Window left in desktop pixels + * @param aTop + * Window top in desktop pixels + * @param aSizeMode + * Window size mode (eg: maximized) + * @param aSizeModeBeforeMinimized + * Window size mode before window got minimized (eg: maximized) + * @param aSidebar + * Sidebar command + */ + restoreDimensions: function ssi_restoreDimensions( + aWindow, + aWidth, + aHeight, + aLeft, + aTop, + aSizeMode, + aSizeModeBeforeMinimized, + aSidebar + ) { + var win = aWindow; + var _this = this; + function win_(aName) { + return _this._getWindowDimension(win, aName); + } + + const dwu = win.windowUtils; + // find available space on the screen where this window is being placed + let screen = lazy.gScreenManager.screenForRect( + aLeft, + aTop, + aWidth, + aHeight + ); + if (screen) { + let screenLeft = {}, + screenTop = {}, + screenWidth = {}, + screenHeight = {}; + screen.GetAvailRectDisplayPix( + screenLeft, + screenTop, + screenWidth, + screenHeight + ); + + // We store aLeft / aTop (screenX/Y) in desktop pixels, see + // _getWindowDimension. + screenLeft = screenLeft.value; + screenTop = screenTop.value; + screenWidth = screenWidth.value; + screenHeight = screenHeight.value; + + let screenBottom = screenTop + screenHeight; + let screenRight = screenLeft + screenWidth; + + // NOTE: contentsScaleFactor is the desktopToDeviceScale of the screen. + // Naming could be more consistent here. + let cssToDesktopScale = + screen.defaultCSSScaleFactor / screen.contentsScaleFactor; + + let slop = SCREEN_EDGE_SLOP * cssToDesktopScale; + + // Pull the window within the screen's bounds (allowing a little slop + // for windows that may be deliberately placed with their border off-screen + // as when Win10 "snaps" a window to the left/right edge -- bug 1276516). + // First, ensure the left edge is large enough... + if (aLeft < screenLeft - slop) { + aLeft = screenLeft; + } + // Then check the resulting right edge, and reduce it if necessary. + let right = aLeft + aWidth * cssToDesktopScale; + if (right > screenRight + slop) { + right = screenRight; + // See if we can move the left edge leftwards to maintain width. + if (aLeft > screenLeft) { + aLeft = Math.max(right - aWidth * cssToDesktopScale, screenLeft); + } + } + // Finally, update aWidth to account for the adjusted left and right + // edges, and convert it back to CSS pixels on the target screen. + aWidth = (right - aLeft) / cssToDesktopScale; + + // And do the same in the vertical dimension. + if (aTop < screenTop - slop) { + aTop = screenTop; + } + let bottom = aTop + aHeight * cssToDesktopScale; + if (bottom > screenBottom + slop) { + bottom = screenBottom; + if (aTop > screenTop) { + aTop = Math.max(bottom - aHeight * cssToDesktopScale, screenTop); + } + } + aHeight = (bottom - aTop) / cssToDesktopScale; + } + + // Suppress animations. + dwu.suppressAnimation(true); + + // We want to make sure users will get their animations back in case an exception is thrown. + try { + // only modify those aspects which aren't correct yet + if ( + !isNaN(aLeft) && + !isNaN(aTop) && + (aLeft != win_("screenX") || aTop != win_("screenY")) + ) { + // moveTo uses CSS pixels relative to aWindow, while aLeft and aRight + // are on desktop pixels, undo the conversion we do in + // _getWindowDimension. + let desktopToCssScale = + aWindow.desktopToDeviceScale / aWindow.devicePixelRatio; + aWindow.moveTo(aLeft * desktopToCssScale, aTop * desktopToCssScale); + } + if ( + aWidth && + aHeight && + (aWidth != win_("width") || aHeight != win_("height")) && + !gResistFingerprintingEnabled + ) { + // Don't resize the window if it's currently maximized and we would + // maximize it again shortly after. + if (aSizeMode != "maximized" || win_("sizemode") != "maximized") { + aWindow.resizeTo(aWidth, aHeight); + } + } + this._windows[ + aWindow.__SSi + ].sizemodeBeforeMinimized = aSizeModeBeforeMinimized; + if ( + aSizeMode && + win_("sizemode") != aSizeMode && + !gResistFingerprintingEnabled + ) { + switch (aSizeMode) { + case "maximized": + aWindow.maximize(); + break; + case "minimized": + if (aSizeModeBeforeMinimized == "maximized") { + aWindow.maximize(); + } + aWindow.minimize(); + break; + case "normal": + aWindow.restore(); + break; + } + } + let sidebarBox = aWindow.document.getElementById("sidebar-box"); + if ( + aSidebar && + (sidebarBox.getAttribute("sidebarcommand") != aSidebar || + !sidebarBox.getAttribute("checked")) + ) { + aWindow.SidebarUI.showInitially(aSidebar); + } + // since resizing/moving a window brings it to the foreground, + // we might want to re-focus the last focused window + if (this.windowToFocus) { + this.windowToFocus.focus(); + } + } finally { + // Enable animations. + dwu.suppressAnimation(false); + } + }, + + /* ........ Disk Access .............. */ + + /** + * Save the current session state to disk, after a delay. + * + * @param aWindow (optional) + * Will mark the given window as dirty so that we will recollect its + * data before we start writing. + */ + saveStateDelayed(aWindow = null) { + if (aWindow) { + DirtyWindows.add(aWindow); + } + + lazy.SessionSaver.runDelayed(); + }, + + /* ........ Auxiliary Functions .............. */ + + /** + * Remove a closed window from the list of closed windows and indicate that + * the change should be notified. + * + * @param index + * The index of the window in this._closedWindows. + * + * @returns Array of closed windows. + */ + _removeClosedWindow(index) { + let windows = this._closedWindows.splice(index, 1); + this._closedObjectsChanged = true; + return windows; + }, + + /** + * Notifies observers that the list of closed tabs and/or windows has changed. + * Waits a tick to allow SessionStorage a chance to register the change. + */ + _notifyOfClosedObjectsChange() { + if (!this._closedObjectsChanged) { + return; + } + this._closedObjectsChanged = false; + lazy.setTimeout(() => { + Services.obs.notifyObservers(null, NOTIFY_CLOSED_OBJECTS_CHANGED); + }, 0); + }, + + /** + * Update the session start time and send a telemetry measurement + * for the number of days elapsed since the session was started. + * + * @param state + * The session state. + */ + _updateSessionStartTime: function ssi_updateSessionStartTime(state) { + // Attempt to load the session start time from the session state + if (state.session && state.session.startTime) { + this._sessionStartTime = state.session.startTime; + } + }, + + /** + * Iterator that yields all currently opened browser windows. + * (Might miss the most recent one.) + * This list is in focus order, but may include minimized windows + * before non-minimized windows. + */ + _browserWindows: { + *[Symbol.iterator]() { + for (let window of lazy.BrowserWindowTracker.orderedWindows) { + if (window.__SSi && !window.closed) { + yield window; + } + } + }, + }, + + /** + * Iterator that yields all currently opened browser windows, + * with minimized windows last. + * (Might miss the most recent window.) + */ + _orderedBrowserWindows: { + *[Symbol.iterator]() { + let windows = lazy.BrowserWindowTracker.orderedWindows; + windows.sort((a, b) => { + if ( + a.windowState == a.STATE_MINIMIZED && + b.windowState != b.STATE_MINIMIZED + ) { + return 1; + } + if ( + a.windowState != a.STATE_MINIMIZED && + b.windowState == b.STATE_MINIMIZED + ) { + return -1; + } + return 0; + }); + for (let window of windows) { + if (window.__SSi && !window.closed) { + yield window; + } + } + }, + }, + + /** + * Returns most recent window + * @returns Window reference + */ + _getTopWindow: function ssi_getTopWindow() { + return lazy.BrowserWindowTracker.getTopWindow({ allowPopups: true }); + }, + + /** + * Calls onClose for windows that are determined to be closed but aren't + * destroyed yet, which would otherwise cause getBrowserState and + * setBrowserState to treat them as open windows. + */ + _handleClosedWindows: function ssi_handleClosedWindows() { + let promises = []; + for (let window of Services.wm.getEnumerator("navigator:browser")) { + if (window.closed) { + promises.push(this.onClose(window)); + } + } + return Promise.all(promises); + }, + + /** + * Store a restore state of a window to this._statesToRestore. The window + * will be given an id that can be used to get the restore state from + * this._statesToRestore. + * + * @param window + * a reference to a window that has a state to restore + * @param state + * an object containing session data + */ + _updateWindowRestoreState(window, state) { + // Store z-index, so that windows can be restored in reversed z-order. + if ("zIndex" in state.windows[0]) { + WINDOW_RESTORE_ZINDICES.set(window, state.windows[0].zIndex); + } + do { + var ID = "window" + Math.random(); + } while (ID in this._statesToRestore); + WINDOW_RESTORE_IDS.set(window, ID); + this._statesToRestore[ID] = state; + }, + + /** + * open a new browser window for a given session state + * called when restoring a multi-window session + * @param aState + * Object containing session data + */ + _openWindowWithState: function ssi_openWindowWithState(aState) { + var argString = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + argString.data = ""; + + // Build feature string + let features; + let winState = aState.windows[0]; + if (winState.chromeFlags) { + features = ["chrome", "suppressanimation"]; + let chromeFlags = winState.chromeFlags; + const allFlags = Ci.nsIWebBrowserChrome.CHROME_ALL; + const hasAll = (chromeFlags & allFlags) == allFlags; + if (hasAll) { + features.push("all"); + } + for (let [flag, onValue, offValue] of CHROME_FLAGS_MAP) { + if (hasAll && allFlags & flag) { + continue; + } + let value = chromeFlags & flag ? onValue : offValue; + if (value) { + features.push(value); + } + } + } else { + // |chromeFlags| is not found. Fallbacks to the old method. + features = ["chrome", "dialog=no", "suppressanimation"]; + let hidden = winState.hidden?.split(",") || []; + if (!hidden.length) { + features.push("all"); + } else { + features.push("resizable"); + WINDOW_HIDEABLE_FEATURES.forEach(aFeature => { + if (!hidden.includes(aFeature)) { + features.push(WINDOW_OPEN_FEATURES_MAP[aFeature] || aFeature); + } + }); + } + } + WINDOW_ATTRIBUTES.forEach(aFeature => { + // Use !isNaN as an easy way to ignore sizemode and check for numbers + if (aFeature in winState && !isNaN(winState[aFeature])) { + features.push(aFeature + "=" + winState[aFeature]); + } + }); + + if (winState.isPrivate) { + features.push("private"); + } + + var window = Services.ww.openWindow( + null, + AppConstants.BROWSER_CHROME_URL, + "_blank", + features.join(","), + argString + ); + + this._updateWindowRestoreState(window, aState); + WINDOW_SHOWING_PROMISES.set(window, lazy.PromiseUtils.defer()); + + return window; + }, + + /** + * whether the user wants to load any other page at startup + * (except the homepage) - needed for determining whether to overwrite the current tabs + * C.f.: nsBrowserContentHandler's defaultArgs implementation. + * @returns bool + */ + _isCmdLineEmpty: function ssi_isCmdLineEmpty(aWindow, aState) { + var pinnedOnly = + aState.windows && + aState.windows.every(win => win.tabs.every(tab => tab.pinned)); + + let hasFirstArgument = aWindow.arguments && aWindow.arguments[0]; + if (!pinnedOnly) { + let defaultArgs = Cc["@mozilla.org/browser/clh;1"].getService( + Ci.nsIBrowserHandler + ).defaultArgs; + if ( + aWindow.arguments && + aWindow.arguments[0] && + aWindow.arguments[0] == defaultArgs + ) { + hasFirstArgument = false; + } + } + + return !hasFirstArgument; + }, + + /** + * on popup windows, the AppWindow's attributes seem not to be set correctly + * we use thus JSDOMWindow attributes for sizemode and normal window attributes + * (and hope for reasonable values when maximized/minimized - since then + * outerWidth/outerHeight aren't the dimensions of the restored window) + * @param aWindow + * Window reference + * @param aAttribute + * String sizemode | width | height | other window attribute + * @returns string + */ + _getWindowDimension: function ssi_getWindowDimension(aWindow, aAttribute) { + if (aAttribute == "sizemode") { + switch (aWindow.windowState) { + case aWindow.STATE_FULLSCREEN: + case aWindow.STATE_MAXIMIZED: + return "maximized"; + case aWindow.STATE_MINIMIZED: + return "minimized"; + default: + return "normal"; + } + } + + // We want to persist the size / position in normal state, so that + // we can restore to them even if the window is currently maximized + // or minimized. However, attributes on window object only reflect + // the current state of the window, so when it isn't in the normal + // sizemode, their values aren't what we want the window to restore + // to. In that case, try to read from the attributes of the root + // element first instead. + if (aWindow.windowState != aWindow.STATE_NORMAL) { + let docElem = aWindow.document.documentElement; + let attr = parseInt(docElem.getAttribute(aAttribute), 10); + if (attr) { + if (aAttribute != "width" && aAttribute != "height") { + return attr; + } + // Width and height attribute report the inner size, but we want + // to store the outer size, so add the difference. + let appWin = aWindow.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow); + let diff = + aAttribute == "width" + ? appWin.outerToInnerWidthDifferenceInCSSPixels + : appWin.outerToInnerHeightDifferenceInCSSPixels; + return attr + diff; + } + } + + switch (aAttribute) { + case "width": + return aWindow.outerWidth; + case "height": + return aWindow.outerHeight; + case "screenX": + case "screenY": + // We use desktop pixels rather than CSS pixels to store window + // positions, see bug 1247335. This allows proper multi-monitor + // positioning in mixed-DPI situations. + // screenX/Y are in CSS pixels for the current window, so, convert them + // to desktop pixels. + return ( + (aWindow[aAttribute] * aWindow.devicePixelRatio) / + aWindow.desktopToDeviceScale + ); + default: + return aAttribute in aWindow ? aWindow[aAttribute] : ""; + } + }, + + /** + * @param aState is a session state + * @param aRecentCrashes is the number of consecutive crashes + * @returns whether a restore page will be needed for the session state + */ + _needsRestorePage: function ssi_needsRestorePage(aState, aRecentCrashes) { + const SIX_HOURS_IN_MS = 6 * 60 * 60 * 1000; + + // don't display the page when there's nothing to restore + let winData = aState.windows || null; + if (!winData || !winData.length) { + return false; + } + + // don't wrap a single about:sessionrestore page + if ( + this._hasSingleTabWithURL(winData, "about:sessionrestore") || + this._hasSingleTabWithURL(winData, "about:welcomeback") + ) { + return false; + } + + // don't automatically restore in Safe Mode + if (Services.appinfo.inSafeMode) { + return true; + } + + let max_resumed_crashes = this._prefBranch.getIntPref( + "sessionstore.max_resumed_crashes" + ); + let sessionAge = + aState.session && + aState.session.lastUpdate && + Date.now() - aState.session.lastUpdate; + + let decision = + max_resumed_crashes != -1 && + (aRecentCrashes > max_resumed_crashes || + (sessionAge && sessionAge >= SIX_HOURS_IN_MS)); + if (decision) { + let key; + if (aRecentCrashes > max_resumed_crashes) { + if (sessionAge && sessionAge >= SIX_HOURS_IN_MS) { + key = "shown_many_crashes_old_session"; + } else { + key = "shown_many_crashes"; + } + } else { + key = "shown_old_session"; + } + Services.telemetry.keyedScalarAdd( + "browser.engagement.sessionrestore_interstitial", + key, + 1 + ); + } + return decision; + }, + + /** + * @param aWinData is the set of windows in session state + * @param aURL is the single URL we're looking for + * @returns whether the window data contains only the single URL passed + */ + _hasSingleTabWithURL(aWinData, aURL) { + if ( + aWinData && + aWinData.length == 1 && + aWinData[0].tabs && + aWinData[0].tabs.length == 1 && + aWinData[0].tabs[0].entries && + aWinData[0].tabs[0].entries.length == 1 + ) { + return aURL == aWinData[0].tabs[0].entries[0].url; + } + return false; + }, + + /** + * Determine if the tab state we're passed is something we should save. This + * is used when closing a tab or closing a window with a single tab + * + * @param aTabState + * The current tab state + * @returns boolean + */ + _shouldSaveTabState: function ssi_shouldSaveTabState(aTabState) { + // If the tab has only a transient about: history entry, no other + // session history, and no userTypedValue, then we don't actually want to + // store this tab's data. + return ( + aTabState.entries.length && + !( + aTabState.entries.length == 1 && + (aTabState.entries[0].url == "about:blank" || + aTabState.entries[0].url == "about:newtab" || + aTabState.entries[0].url == "about:privatebrowsing") && + !aTabState.userTypedValue + ) + ); + }, + + /** + * Determine if the tab state we're passed is something we should keep to be + * reopened at session restore. This is used when we are saving the current + * session state to disk. This method is very similar to _shouldSaveTabState, + * however, "about:blank" and "about:newtab" tabs will still be saved to disk. + * + * @param aTabState + * The current tab state + * @returns boolean + */ + _shouldSaveTab: function ssi_shouldSaveTab(aTabState) { + // If the tab has one of the following transient about: history entry, no + // userTypedValue, and no customizemode attribute, then we don't actually + // want to write this tab's data to disk. + return ( + aTabState.userTypedValue || + (aTabState.attributes && aTabState.attributes.customizemode == "true") || + (aTabState.entries.length && + aTabState.entries[0].url != "about:privatebrowsing") + ); + }, + + /** + * This is going to take a state as provided at startup (via + * SessionStartup.state) and split it into 2 parts. The first part + * (defaultState) will be a state that should still be restored at startup, + * while the second part (state) is a state that should be saved for later. + * defaultState will be comprised of windows with only pinned tabs, extracted + * from state. It will also contain window position information. + * + * defaultState will be restored at startup. state will be passed into + * LastSession and will be kept in case the user explicitly wants + * to restore the previous session (publicly exposed as restoreLastSession). + * + * @param state + * The state, presumably from SessionStartup.state + * @returns [defaultState, state] + */ + _prepDataForDeferredRestore: function ssi_prepDataForDeferredRestore(state) { + // Make sure that we don't modify the global state as provided by + // SessionStartup.state. + state = Cu.cloneInto(state, {}); + + let defaultState = { windows: [], selectedWindow: 1 }; + + state.selectedWindow = state.selectedWindow || 1; + + // Look at each window, remove pinned tabs, adjust selectedindex, + // remove window if necessary. + for (let wIndex = 0; wIndex < state.windows.length; ) { + let window = state.windows[wIndex]; + window.selected = window.selected || 1; + // We're going to put the state of the window into this object + let pinnedWindowState = { tabs: [] }; + for (let tIndex = 0; tIndex < window.tabs.length; ) { + if (window.tabs[tIndex].pinned) { + // Adjust window.selected + if (tIndex + 1 < window.selected) { + window.selected -= 1; + } else if (tIndex + 1 == window.selected) { + pinnedWindowState.selected = pinnedWindowState.tabs.length + 1; + } + // + 1 because the tab isn't actually in the array yet + + // Now add the pinned tab to our window + pinnedWindowState.tabs = pinnedWindowState.tabs.concat( + window.tabs.splice(tIndex, 1) + ); + // We don't want to increment tIndex here. + continue; + } + tIndex++; + } + + // At this point the window in the state object has been modified (or not) + // We want to build the rest of this new window object if we have pinnedTabs. + if (pinnedWindowState.tabs.length) { + // First get the other attributes off the window + WINDOW_ATTRIBUTES.forEach(function(attr) { + if (attr in window) { + pinnedWindowState[attr] = window[attr]; + delete window[attr]; + } + }); + // We're just copying position data into the pinned window. + // Not copying over: + // - _closedTabs + // - extData + // - isPopup + // - hidden + + // Assign a unique ID to correlate the window to be opened with the + // remaining data + window.__lastSessionWindowID = pinnedWindowState.__lastSessionWindowID = + "" + Date.now() + Math.random(); + + // Actually add this window to our defaultState + defaultState.windows.push(pinnedWindowState); + // Remove the window from the state if it doesn't have any tabs + if (!window.tabs.length) { + if (wIndex + 1 <= state.selectedWindow) { + state.selectedWindow -= 1; + } else if (wIndex + 1 == state.selectedWindow) { + defaultState.selectedIndex = defaultState.windows.length + 1; + } + + state.windows.splice(wIndex, 1); + // We don't want to increment wIndex here. + continue; + } + } + wIndex++; + } + + return [defaultState, state]; + }, + + _sendRestoreCompletedNotifications: function ssi_sendRestoreCompletedNotifications() { + // not all windows restored, yet + if (this._restoreCount > 1) { + this._restoreCount--; + return; + } + + // observers were already notified + if (this._restoreCount == -1) { + return; + } + + // This was the last window restored at startup, notify observers. + if (!this._browserSetState) { + Services.obs.notifyObservers(null, NOTIFY_WINDOWS_RESTORED); + this._deferredAllWindowsRestored.resolve(); + } else { + // _browserSetState is used only by tests, and it uses an alternate + // notification in order not to retrigger startup observers that + // are listening for NOTIFY_WINDOWS_RESTORED. + Services.obs.notifyObservers(null, NOTIFY_BROWSER_STATE_RESTORED); + } + + this._browserSetState = false; + this._restoreCount = -1; + }, + + /** + * Set the given window's busy state + * @param aWindow the window + * @param aValue the window's busy state + */ + _setWindowStateBusyValue: function ssi_changeWindowStateBusyValue( + aWindow, + aValue + ) { + this._windows[aWindow.__SSi].busy = aValue; + + // Keep the to-be-restored state in sync because that is returned by + // getWindowState() as long as the window isn't loaded, yet. + if (!this._isWindowLoaded(aWindow)) { + let stateToRestore = this._statesToRestore[ + WINDOW_RESTORE_IDS.get(aWindow) + ].windows[0]; + stateToRestore.busy = aValue; + } + }, + + /** + * Set the given window's state to 'not busy'. + * @param aWindow the window + */ + _setWindowStateReady: function ssi_setWindowStateReady(aWindow) { + let newCount = (this._windowBusyStates.get(aWindow) || 0) - 1; + if (newCount < 0) { + throw new Error("Invalid window busy state (less than zero)."); + } + this._windowBusyStates.set(aWindow, newCount); + + if (newCount == 0) { + this._setWindowStateBusyValue(aWindow, false); + this._sendWindowStateReadyEvent(aWindow); + } + }, + + /** + * Set the given window's state to 'busy'. + * @param aWindow the window + */ + _setWindowStateBusy: function ssi_setWindowStateBusy(aWindow) { + let newCount = (this._windowBusyStates.get(aWindow) || 0) + 1; + this._windowBusyStates.set(aWindow, newCount); + + if (newCount == 1) { + this._setWindowStateBusyValue(aWindow, true); + this._sendWindowStateBusyEvent(aWindow); + } + }, + + /** + * Dispatch an SSWindowStateReady event for the given window. + * @param aWindow the window + */ + _sendWindowStateReadyEvent: function ssi_sendWindowStateReadyEvent(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowStateReady", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch an SSWindowStateBusy event for the given window. + * @param aWindow the window + */ + _sendWindowStateBusyEvent: function ssi_sendWindowStateBusyEvent(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowStateBusy", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch the SSWindowRestoring event for the given window. + * @param aWindow + * The window which is going to be restored + */ + _sendWindowRestoringNotification(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowRestoring", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch the SSWindowRestored event for the given window. + * @param aWindow + * The window which has been restored + */ + _sendWindowRestoredNotification(aWindow) { + let event = aWindow.document.createEvent("Events"); + event.initEvent("SSWindowRestored", true, false); + aWindow.dispatchEvent(event); + }, + + /** + * Dispatch the SSTabRestored event for the given tab. + * @param aTab + * The tab which has been restored + * @param aIsRemotenessUpdate + * True if this tab was restored due to flip from running from + * out-of-main-process to in-main-process or vice-versa. + */ + _sendTabRestoredNotification(aTab, aIsRemotenessUpdate) { + let event = aTab.ownerDocument.createEvent("CustomEvent"); + event.initCustomEvent("SSTabRestored", true, false, { + isRemotenessUpdate: aIsRemotenessUpdate, + }); + aTab.dispatchEvent(event); + }, + + /** + * @param aWindow + * Window reference + * @returns whether this window's data is still cached in _statesToRestore + * because it's not fully loaded yet + */ + _isWindowLoaded: function ssi_isWindowLoaded(aWindow) { + return !WINDOW_RESTORE_IDS.has(aWindow); + }, + + /** + * Resize this._closedWindows to the value of the pref, except in the case + * where we don't have any non-popup windows on Windows and Linux. Then we must + * resize such that we have at least one non-popup window. + */ + _capClosedWindows: function ssi_capClosedWindows() { + if (this._closedWindows.length <= this._max_windows_undo) { + return; + } + let spliceTo = this._max_windows_undo; + if (AppConstants.platform != "macosx") { + let normalWindowIndex = 0; + // try to find a non-popup window in this._closedWindows + while ( + normalWindowIndex < this._closedWindows.length && + !!this._closedWindows[normalWindowIndex].isPopup + ) { + normalWindowIndex++; + } + if (normalWindowIndex >= this._max_windows_undo) { + spliceTo = normalWindowIndex + 1; + } + } + if (spliceTo < this._closedWindows.length) { + this._closedWindows.splice(spliceTo, this._closedWindows.length); + this._closedObjectsChanged = true; + } + }, + + /** + * Clears the set of windows that are "resurrected" before writing to disk to + * make closing windows one after the other until shutdown work as expected. + * + * This function should only be called when we are sure that there has been + * a user action that indicates the browser is actively being used and all + * windows that have been closed before are not part of a series of closing + * windows. + */ + _clearRestoringWindows: function ssi_clearRestoringWindows() { + for (let i = 0; i < this._closedWindows.length; i++) { + delete this._closedWindows[i]._shouldRestore; + } + }, + + /** + * Reset state to prepare for a new session state to be restored. + */ + _resetRestoringState: function ssi_initRestoringState() { + TabRestoreQueue.reset(); + this._tabsRestoringCount = 0; + }, + + /** + * Reset the restoring state for a particular tab. This will be called when + * removing a tab or when a tab needs to be reset (it's being overwritten). + * + * @param aTab + * The tab that will be "reset" + */ + _resetLocalTabRestoringState(aTab) { + let browser = aTab.linkedBrowser; + + // Keep the tab's previous state for later in this method + let previousState = TAB_STATE_FOR_BROWSER.get(browser); + + if (!previousState) { + Cu.reportError("Given tab is not restoring."); + return; + } + + // The browser is no longer in any sort of restoring state. + TAB_STATE_FOR_BROWSER.delete(browser); + + if (Services.appinfo.sessionHistoryInParent) { + this._restoreListeners.get(browser.permanentKey)?.unregister(); + browser.browsingContext.clearRestoreState(); + } + + aTab.removeAttribute("pending"); + + if (previousState == TAB_STATE_RESTORING) { + if (this._tabsRestoringCount) { + this._tabsRestoringCount--; + } + } else if (previousState == TAB_STATE_NEEDS_RESTORE) { + // Make sure that the tab is removed from the list of tabs to restore. + // Again, this is normally done in restoreTabContent, but that isn't being called + // for this tab. + TabRestoreQueue.remove(aTab); + } + }, + + _resetTabRestoringState(tab) { + let browser = tab.linkedBrowser; + + if (!TAB_STATE_FOR_BROWSER.has(browser)) { + Cu.reportError("Given tab is not restoring."); + return; + } + + if (!Services.appinfo.sessionHistoryInParent) { + browser.messageManager.sendAsyncMessage("SessionStore:resetRestore", {}); + } + this._resetLocalTabRestoringState(tab); + }, + + /** + * Each fresh tab starts out with epoch=0. This function can be used to + * start a next epoch by incrementing the current value. It will enables us + * to ignore stale messages sent from previous epochs. The function returns + * the new epoch ID for the given |browser|. + */ + startNextEpoch(permanentKey) { + let next = this.getCurrentEpoch(permanentKey) + 1; + this._browserEpochs.set(permanentKey, next); + return next; + }, + + /** + * Returns the current epoch for the given <browser>. If we haven't assigned + * a new epoch this will default to zero for new tabs. + */ + getCurrentEpoch(permanentKey) { + return this._browserEpochs.get(permanentKey) || 0; + }, + + /** + * Each time a <browser> element is restored, we increment its "epoch". To + * check if a message from content-sessionStore.js is out of date, we can + * compare the epoch received with the message to the <browser> element's + * epoch. This function does that, and returns true if |epoch| is up-to-date + * with respect to |browser|. + */ + isCurrentEpoch(permanentKey, epoch) { + return this.getCurrentEpoch(permanentKey) == epoch; + }, + + /** + * Resets the epoch for a given <browser>. We need to this every time we + * receive a hint that a new docShell has been loaded into the browser as + * the frame script starts out with epoch=0. + */ + resetEpoch(permanentKey, frameLoader = null) { + this._browserEpochs.delete(permanentKey); + if (frameLoader) { + frameLoader.requestEpochUpdate(0); + } + }, + + /** + * Countdown for a given duration, skipping beats if the computer is too busy, + * sleeping or otherwise unavailable. + * + * @param {number} delay An approximate delay to wait in milliseconds (rounded + * up to the closest second). + * + * @return Promise + */ + looseTimer(delay) { + let DELAY_BEAT = 1000; + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + let beats = Math.ceil(delay / DELAY_BEAT); + let deferred = lazy.PromiseUtils.defer(); + timer.initWithCallback( + function() { + if (beats <= 0) { + deferred.resolve(); + } + --beats; + }, + DELAY_BEAT, + Ci.nsITimer.TYPE_REPEATING_PRECISE_CAN_SKIP + ); + // Ensure that the timer is both canceled once we are done with it + // and not garbage-collected until then. + deferred.promise.then( + () => timer.cancel(), + () => timer.cancel() + ); + return deferred; + }, + + /** + * Builds a single nsISessionStoreRestoreData tree for the provided |formdata| + * and |scroll| trees. + */ + buildRestoreData(formdata, scroll) { + function addFormEntries(root, fields, isXpath) { + for (let [key, value] of Object.entries(fields)) { + switch (typeof value) { + case "string": + root.addTextField(isXpath, key, value); + break; + case "boolean": + root.addCheckbox(isXpath, key, value); + break; + case "object": { + if (value === null) { + break; + } + if ( + value.hasOwnProperty("type") && + value.hasOwnProperty("fileList") + ) { + root.addFileList(isXpath, key, value.type, value.fileList); + break; + } + if ( + value.hasOwnProperty("selectedIndex") && + value.hasOwnProperty("value") + ) { + root.addSingleSelect( + isXpath, + key, + value.selectedIndex, + value.value + ); + break; + } + if ( + key === "sessionData" && + ["about:sessionrestore", "about:welcomeback"].includes( + formdata.url + ) + ) { + root.addTextField(isXpath, key, JSON.stringify(value)); + break; + } + if (Array.isArray(value)) { + root.addMultipleSelect(isXpath, key, value); + break; + } + } + } + } + } + + let root = SessionStoreUtils.constructSessionStoreRestoreData(); + if (scroll?.hasOwnProperty("scroll")) { + root.scroll = scroll.scroll; + } + if (formdata?.hasOwnProperty("url")) { + root.url = formdata.url; + if (formdata.hasOwnProperty("innerHTML")) { + // eslint-disable-next-line no-unsanitized/property + root.innerHTML = formdata.innerHTML; + } + if (formdata.hasOwnProperty("xpath")) { + addFormEntries(root, formdata.xpath, /* isXpath */ true); + } + if (formdata.hasOwnProperty("id")) { + addFormEntries(root, formdata.id, /* isXpath */ false); + } + } + let childrenLength = Math.max( + scroll?.children?.length || 0, + formdata?.children?.length || 0 + ); + for (let i = 0; i < childrenLength; i++) { + root.addChild( + this.buildRestoreData(formdata?.children?.[i], scroll?.children?.[i]), + i + ); + } + return root; + }, + + _waitForStateStop(browser, expectedURL = null) { + const deferred = lazy.PromiseUtils.defer(); + + const listener = { + unregister(reject = true) { + if (reject) { + deferred.reject(); + } + + SessionStoreInternal._restoreListeners.delete(browser.permanentKey); + + try { + browser.removeProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW + ); + } catch {} // May have already gotten rid of the browser's webProgress. + }, + + onStateChange(webProgress, request, stateFlags, status) { + if ( + webProgress.isTopLevel && + stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW && + stateFlags & Ci.nsIWebProgressListener.STATE_STOP + ) { + // FIXME: We sometimes see spurious STATE_STOP events for about:blank + // loads, so we have to account for that here. + let aboutBlankOK = !expectedURL || expectedURL === "about:blank"; + let url = request.QueryInterface(Ci.nsIChannel).originalURI.spec; + if (url !== "about:blank" || aboutBlankOK) { + this.unregister(false); + deferred.resolve(); + } + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + + this._restoreListeners.get(browser.permanentKey)?.unregister(); + this._restoreListeners.set(browser.permanentKey, listener); + + browser.addProgressListener( + listener, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW + ); + + return deferred.promise; + }, + + _listenForNavigations(browser, callbacks) { + const listener = { + unregister() { + browser.browsingContext?.sessionHistory?.removeSHistoryListener(this); + + try { + browser.removeProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW + ); + } catch {} // May have already gotten rid of the browser's webProgress. + + SessionStoreInternal._restoreListeners.delete(browser.permanentKey); + }, + + OnHistoryReload() { + this.unregister(); + return callbacks.onHistoryReload(); + }, + + // TODO(kashav): ContentRestore.sys.mjs handles OnHistoryNewEntry + // separately, so we should eventually support that here as well. + OnHistoryNewEntry() {}, + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReplaceEntry() {}, + + onStateChange(webProgress, request, stateFlags, status) { + if ( + webProgress.isTopLevel && + stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW && + stateFlags & Ci.nsIWebProgressListener.STATE_START + ) { + this.unregister(); + callbacks.onStartRequest(); + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + + this._restoreListeners.get(browser.permanentKey)?.unregister(); + this._restoreListeners.set(browser.permanentKey, listener); + + browser.browsingContext?.sessionHistory?.addSHistoryListener(listener); + + browser.addProgressListener( + listener, + Ci.nsIWebProgress.NOTIFY_STATE_WINDOW + ); + }, + + /** + * This mirrors ContentRestore.restoreHistory() for parent process session + * history restores. + */ + _restoreHistory(browser, data) { + if (!Services.appinfo.sessionHistoryInParent) { + throw new Error("This function should only be used with SHIP"); + } + + this._tabStateToRestore.set(browser.permanentKey, data); + + // In case about:blank isn't done yet. + // XXX(kashav): Does this actually accomplish anything? Can we remove? + browser.stop(); + + lazy.SessionHistory.restoreFromParent( + browser.browsingContext.sessionHistory, + data.tabData + ); + + let url = data.tabData?.entries[data.tabData.index - 1]?.url; + let disallow = data.tabData?.disallow; + + let promise = SessionStoreUtils.restoreDocShellState( + browser.browsingContext, + url, + disallow + ); + this._tabStateRestorePromises.set(browser.permanentKey, promise); + + const onResolve = () => { + if (TAB_STATE_FOR_BROWSER.get(browser) !== TAB_STATE_RESTORING) { + this._listenForNavigations(browser, { + // The history entry was reloaded before we began restoring tab + // content, just proceed as we would normally. + onHistoryReload: () => { + this._restoreTabContent(browser); + return false; + }, + + // Some foreign code, like an extension, loaded a new URI on the + // browser. We no longer want to restore saved tab data, but may + // still have browser state that needs to be restored. + onStartRequest: () => { + this._tabStateToRestore.delete(browser.permanentKey); + this._restoreTabContent(browser); + }, + }); + } + + this._tabStateRestorePromises.delete(browser.permanentKey); + + this._restoreHistoryComplete(browser, data); + }; + + promise.then(onResolve).catch(() => {}); + }, + + /** + * Either load the saved typed value or restore the active history entry. + * If neither is possible, just load an empty document. + */ + _restoreTabEntry(browser, tabData) { + let url = "about:blank"; + let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY; + + if (tabData.userTypedValue && tabData.userTypedClear) { + url = tabData.userTypedValue; + loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP; + } else if (tabData.entries.length) { + return SessionStoreUtils.initializeRestore( + browser.browsingContext, + this.buildRestoreData(tabData.formdata, tabData.scroll) + ); + } + + let loadPromise = this._waitForStateStop(browser, url); + + browser.browsingContext.loadURI(url, { + loadFlags, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + + return loadPromise; + }, + + /** + * This mirrors ContentRestore.restoreTabContent() for parent process session + * history restores. + */ + _restoreTabContent(browser, options = {}) { + if (!Services.appinfo.sessionHistoryInParent) { + throw new Error("This function should only be used with SHIP"); + } + + this._restoreListeners.get(browser.permanentKey)?.unregister(); + + this._restoreTabContentStarted(browser, options); + + let state = this._tabStateToRestore.get(browser.permanentKey); + this._tabStateToRestore.delete(browser.permanentKey); + + let promises = [this._tabStateRestorePromises.get(browser.permanentKey)]; + + if (state) { + promises.push(this._restoreTabEntry(browser, state.tabData)); + } else { + // The browser started another load, so we decided to not restore + // saved tab data. We should still wait for that new load to finish + // before proceeding. + promises.push(this._waitForStateStop(browser)); + } + + Promise.allSettled(promises).then(() => { + this._restoreTabContentComplete(browser, options); + }); + }, + + _sendRestoreTabContent(browser, options) { + if (Services.appinfo.sessionHistoryInParent) { + this._restoreTabContent(browser, options); + } else { + browser.messageManager.sendAsyncMessage( + "SessionStore:restoreTabContent", + options + ); + } + }, + + _restoreHistoryComplete(browser, data) { + let win = browser.ownerGlobal; + let tab = win?.gBrowser.getTabForBrowser(browser); + if (!tab) { + return; + } + + // Notify the tabbrowser that the tab chrome has been restored. + let tabData = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + + // Update tab label and icon again after the tab history was updated. + this.updateTabLabelAndIcon(tab, tabData); + + let event = win.document.createEvent("Events"); + event.initEvent("SSTabRestoring", true, false); + tab.dispatchEvent(event); + }, + + _restoreTabContentStarted(browser, data) { + let win = browser.ownerGlobal; + let tab = win?.gBrowser.getTabForBrowser(browser); + if (!tab) { + return; + } + + let initiatedBySessionStore = + TAB_STATE_FOR_BROWSER.get(browser) != TAB_STATE_NEEDS_RESTORE; + let isNavigateAndRestore = + data.reason == RESTORE_TAB_CONTENT_REASON.NAVIGATE_AND_RESTORE; + + // We need to be careful when restoring the urlbar's search mode because + // we race a call to gURLBar.setURI due to the location change. setURI + // will exit search mode and set gURLBar.value to the restored URL, + // clobbering any search mode and userTypedValue we restore here. If + // this is a typical restore -- restoring on startup or restoring a + // closed tab for example -- then we need to restore search mode after + // that setURI call, and so we wait until restoreTabContentComplete, at + // which point setURI will have been called. If this is not a typical + // restore -- it was not initiated by session store or it's due to a + // remoteness change -- then we do not want to restore search mode at + // all, and so we remove it from the tab state cache. In particular, if + // the restore is due to a remoteness change, then the user is loading a + // new URL and the current search mode should not be carried over to it. + let cacheState = lazy.TabStateCache.get(browser.permanentKey); + if (cacheState.searchMode) { + if (!initiatedBySessionStore || isNavigateAndRestore) { + lazy.TabStateCache.update(browser.permanentKey, { + searchMode: null, + userTypedValue: null, + }); + } + return; + } + + if (!initiatedBySessionStore) { + // If a load not initiated by sessionstore was started in a + // previously pending tab. Mark the tab as no longer pending. + this.markTabAsRestoring(tab); + } else if (!isNavigateAndRestore) { + // If the user was typing into the URL bar when we crashed, but hadn't hit + // enter yet, then we just need to write that value to the URL bar without + // loading anything. This must happen after the load, as the load will clear + // userTypedValue. + // + // Note that we only want to do that if we're restoring state for reasons + // _other_ than a navigateAndRestore remoteness-flip, as such a flip + // implies that the user was navigating. + let tabData = lazy.TabState.collect(tab, TAB_CUSTOM_VALUES.get(tab)); + if ( + tabData.userTypedValue && + !tabData.userTypedClear && + !browser.userTypedValue + ) { + browser.userTypedValue = tabData.userTypedValue; + if (tab.selected) { + win.gURLBar.setURI(); + } + } + + // Remove state we don't need any longer. + lazy.TabStateCache.update(browser.permanentKey, { + userTypedValue: null, + userTypedClear: null, + }); + } + }, + + _restoreTabContentComplete(browser, data) { + let win = browser.ownerGlobal; + let tab = browser.ownerGlobal?.gBrowser.getTabForBrowser(browser); + if (!tab) { + return; + } + // Restore search mode and its search string in userTypedValue, if + // appropriate. + let cacheState = lazy.TabStateCache.get(browser.permanentKey); + if (cacheState.searchMode) { + win.gURLBar.setSearchMode(cacheState.searchMode, browser); + browser.userTypedValue = cacheState.userTypedValue; + if (tab.selected) { + win.gURLBar.setURI(); + } + lazy.TabStateCache.update(browser.permanentKey, { + searchMode: null, + userTypedValue: null, + }); + } + + // This callback is used exclusively by tests that want to + // monitor the progress of network loads. + if (gDebuggingEnabled) { + Services.obs.notifyObservers(browser, NOTIFY_TAB_RESTORED); + } + + SessionStoreInternal._resetLocalTabRestoringState(tab); + SessionStoreInternal.restoreNextTab(); + + this._sendTabRestoredNotification(tab, data.isRemotenessUpdate); + + Services.obs.notifyObservers(null, "sessionstore-one-or-no-tab-restored"); + }, + + /** + * Send the "SessionStore:restoreHistory" message to content, triggering a + * content restore. This method is intended to be used internally by + * SessionStore, as it also ensures that permissions are avaliable in the + * content process before triggering the history restore in the content + * process. + * + * @param browser The browser to transmit the permissions for + * @param options The options data to send to content. + */ + _sendRestoreHistory(browser, options) { + if (options.tabData.storage) { + SessionStoreUtils.restoreSessionStorageFromParent( + browser.browsingContext, + options.tabData.storage + ); + delete options.tabData.storage; + } + + if (Services.appinfo.sessionHistoryInParent) { + this._restoreHistory(browser, options); + } else { + browser.messageManager.sendAsyncMessage( + "SessionStore:restoreHistory", + options + ); + } + + if (browser && browser.frameLoader) { + browser.frameLoader.requestEpochUpdate(options.epoch); + } + }, + + // Flush out session history state so that it can be used to restore the state + // into a new process in `finishTabRemotenessChange`. + // + // NOTE: This codepath is temporary while the Fission Session History rewrite + // is in process, and will be removed & replaced once that rewrite is + // complete. (bug 1645062) + async prepareToChangeRemoteness(aBrowser) { + aBrowser.messageManager.sendAsyncMessage( + "SessionStore:prepareForProcessChange" + ); + await lazy.TabStateFlusher.flush(aBrowser); + }, + + // Handle finishing the remoteness change for a tab by restoring session + // history state into it, and resuming the ongoing network load. + // + // NOTE: This codepath is temporary while the Fission Session History rewrite + // is in process, and will be removed & replaced once that rewrite is + // complete. (bug 1645062) + finishTabRemotenessChange(aTab, aSwitchId) { + let window = aTab.ownerGlobal; + if (!window || !window.__SSi || window.closed) { + return; + } + + let tabState = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); + let options = { + restoreImmediately: true, + restoreContentReason: RESTORE_TAB_CONTENT_REASON.NAVIGATE_AND_RESTORE, + isRemotenessUpdate: true, + loadArguments: { + redirectLoadSwitchId: aSwitchId, + // As we're resuming a load which has been redirected from another + // process, record the history index which is currently being requested. + // It has to be offset by 1 to get back to native history indices from + // SessionStore history indicies. + redirectHistoryIndex: tabState.requestedIndex - 1, + }, + }; + + // Need to reset restoring tabs. + if (TAB_STATE_FOR_BROWSER.has(aTab.linkedBrowser)) { + this._resetLocalTabRestoringState(aTab); + } + + // Restore the state into the tab. + this.restoreTab(aTab, tabState, options); + }, +}; + +/** + * Priority queue that keeps track of a list of tabs to restore and returns + * the tab we should restore next, based on priority rules. We decide between + * pinned, visible and hidden tabs in that and FIFO order. Hidden tabs are only + * restored with restore_hidden_tabs=true. + */ +var TabRestoreQueue = { + // The separate buckets used to store tabs. + tabs: { priority: [], visible: [], hidden: [] }, + + // Preferences used by the TabRestoreQueue to determine which tabs + // are restored automatically and which tabs will be on-demand. + prefs: { + // Lazy getter that returns whether tabs are restored on demand. + get restoreOnDemand() { + let updateValue = () => { + let value = Services.prefs.getBoolPref(PREF); + let definition = { value, configurable: true }; + Object.defineProperty(this, "restoreOnDemand", definition); + return value; + }; + + const PREF = "browser.sessionstore.restore_on_demand"; + Services.prefs.addObserver(PREF, updateValue); + return updateValue(); + }, + + // Lazy getter that returns whether pinned tabs are restored on demand. + get restorePinnedTabsOnDemand() { + let updateValue = () => { + let value = Services.prefs.getBoolPref(PREF); + let definition = { value, configurable: true }; + Object.defineProperty(this, "restorePinnedTabsOnDemand", definition); + return value; + }; + + const PREF = "browser.sessionstore.restore_pinned_tabs_on_demand"; + Services.prefs.addObserver(PREF, updateValue); + return updateValue(); + }, + + // Lazy getter that returns whether we should restore hidden tabs. + get restoreHiddenTabs() { + let updateValue = () => { + let value = Services.prefs.getBoolPref(PREF); + let definition = { value, configurable: true }; + Object.defineProperty(this, "restoreHiddenTabs", definition); + return value; + }; + + const PREF = "browser.sessionstore.restore_hidden_tabs"; + Services.prefs.addObserver(PREF, updateValue); + return updateValue(); + }, + }, + + // Resets the queue and removes all tabs. + reset() { + this.tabs = { priority: [], visible: [], hidden: [] }; + }, + + // Adds a tab to the queue and determines its priority bucket. + add(tab) { + let { priority, hidden, visible } = this.tabs; + + if (tab.pinned) { + priority.push(tab); + } else if (tab.hidden) { + hidden.push(tab); + } else { + visible.push(tab); + } + }, + + // Removes a given tab from the queue, if it's in there. + remove(tab) { + let { priority, hidden, visible } = this.tabs; + + // We'll always check priority first since we don't + // have an indicator if a tab will be there or not. + let set = priority; + let index = set.indexOf(tab); + + if (index == -1) { + set = tab.hidden ? hidden : visible; + index = set.indexOf(tab); + } + + if (index > -1) { + set.splice(index, 1); + } + }, + + // Returns and removes the tab with the highest priority. + shift() { + let set; + let { priority, hidden, visible } = this.tabs; + + let { restoreOnDemand, restorePinnedTabsOnDemand } = this.prefs; + let restorePinned = !(restoreOnDemand && restorePinnedTabsOnDemand); + if (restorePinned && priority.length) { + set = priority; + } else if (!restoreOnDemand) { + if (visible.length) { + set = visible; + } else if (this.prefs.restoreHiddenTabs && hidden.length) { + set = hidden; + } + } + + return set && set.shift(); + }, + + // Moves a given tab from the 'hidden' to the 'visible' bucket. + hiddenToVisible(tab) { + let { hidden, visible } = this.tabs; + let index = hidden.indexOf(tab); + + if (index > -1) { + hidden.splice(index, 1); + visible.push(tab); + } + }, + + // Moves a given tab from the 'visible' to the 'hidden' bucket. + visibleToHidden(tab) { + let { visible, hidden } = this.tabs; + let index = visible.indexOf(tab); + + if (index > -1) { + visible.splice(index, 1); + hidden.push(tab); + } + }, + + /** + * Returns true if the passed tab is in one of the sets that we're + * restoring content in automatically. + * + * @param tab (<xul:tab>) + * The tab to check + * @returns bool + */ + willRestoreSoon(tab) { + let { priority, hidden, visible } = this.tabs; + let { + restoreOnDemand, + restorePinnedTabsOnDemand, + restoreHiddenTabs, + } = this.prefs; + let restorePinned = !(restoreOnDemand && restorePinnedTabsOnDemand); + let candidateSet = []; + + if (restorePinned && priority.length) { + candidateSet.push(...priority); + } + + if (!restoreOnDemand) { + if (visible.length) { + candidateSet.push(...visible); + } + + if (restoreHiddenTabs && hidden.length) { + candidateSet.push(...hidden); + } + } + + return candidateSet.indexOf(tab) > -1; + }, +}; + +// A map storing a closed window's state data until it goes aways (is GC'ed). +// This ensures that API clients can still read (but not write) states of +// windows they still hold a reference to but we don't. +var DyingWindowCache = { + _data: new WeakMap(), + + has(window) { + return this._data.has(window); + }, + + get(window) { + return this._data.get(window); + }, + + set(window, data) { + this._data.set(window, data); + }, + + remove(window) { + this._data.delete(window); + }, +}; + +// A weak set of dirty windows. We use it to determine which windows we need to +// recollect data for when getCurrentState() is called. +var DirtyWindows = { + _data: new WeakMap(), + + has(window) { + return this._data.has(window); + }, + + add(window) { + return this._data.set(window, true); + }, + + remove(window) { + this._data.delete(window); + }, + + clear(window) { + this._data = new WeakMap(); + }, +}; + +// The state from the previous session (after restoring pinned tabs). This +// state is persisted and passed through to the next session during an app +// restart to make the third party add-on warning not trash the deferred +// session +var LastSession = { + _state: null, + + get canRestore() { + return !!this._state; + }, + + getState() { + return this._state; + }, + + setState(state) { + this._state = state; + }, + + clear(silent = false) { + if (this._state) { + this._state = null; + if (!silent) { + Services.obs.notifyObservers(null, NOTIFY_LAST_SESSION_CLEARED); + } + } + }, +}; + +// Exposed for tests +export const _LastSession = LastSession; diff --git a/browser/components/sessionstore/SessionWriter.sys.mjs b/browser/components/sessionstore/SessionWriter.sys.mjs new file mode 100644 index 0000000000..37f565e4af --- /dev/null +++ b/browser/components/sessionstore/SessionWriter.sys.mjs @@ -0,0 +1,396 @@ +/* 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/. */ + +/** + * We just started (we haven't written anything to disk yet) from + * `Paths.clean`. The backup directory may not exist. + */ +const STATE_CLEAN = "clean"; +/** + * We know that `Paths.recovery` is good, either because we just read + * it (we haven't written anything to disk yet) or because have + * already written once to `Paths.recovery` during this session. + * `Paths.clean` is absent or invalid. The backup directory exists. + */ +const STATE_RECOVERY = "recovery"; +/** + * We just started from `Paths.upgradeBackup` (we haven't written + * anything to disk yet). Both `Paths.clean`, `Paths.recovery` and + * `Paths.recoveryBackup` are absent or invalid. The backup directory + * exists. + */ +const STATE_UPGRADE_BACKUP = "upgradeBackup"; +/** + * We just started without a valid session store file (we haven't + * written anything to disk yet). The backup directory may not exist. + */ +const STATE_EMPTY = "empty"; + +var sessionFileIOMutex = Promise.resolve(); +// Ensure that we don't do concurrent I/O on the same file. +// Example usage: +// const unlock = await lockIOWithMutex(); +// try { +// ... (Do I/O work here.) +// } finally { unlock(); } +function lockIOWithMutex() { + // Return a Promise that resolves when the mutex is free. + return new Promise(unlock => { + // Overwrite the mutex variable with a chained-on, new Promise. The Promise + // we returned to the caller can be called to resolve that new Promise + // and unlock the mutex. + sessionFileIOMutex = sessionFileIOMutex.then(() => { + return new Promise(unlock); + }); + }); +} + +/** + * Interface dedicated to handling I/O for Session Store. + */ +export const SessionWriter = { + init(origin, useOldExtension, paths, prefs = {}) { + return SessionWriterInternal.init(origin, useOldExtension, paths, prefs); + }, + + /** + * Write the contents of the session file. + * @param state - May get changed on shutdown. + */ + async write(state, options = {}) { + const unlock = await lockIOWithMutex(); + try { + return await SessionWriterInternal.write(state, options); + } finally { + unlock(); + } + }, + + async wipe() { + const unlock = await lockIOWithMutex(); + try { + return await SessionWriterInternal.wipe(); + } finally { + unlock(); + } + }, +}; + +const SessionWriterInternal = { + // Path to the files used by the SessionWriter + Paths: null, + + /** + * The current state of the session file, as one of the following strings: + * - "empty" if we have started without any sessionstore; + * - one of "clean", "recovery", "recoveryBackup", "cleanBackup", + * "upgradeBackup" if we have started by loading the corresponding file. + */ + state: null, + + /** + * A flag that indicates we loaded a session file with the deprecated .js extension. + */ + useOldExtension: false, + + /** + * Number of old upgrade backups that are being kept + */ + maxUpgradeBackups: null, + + /** + * Initialize (or reinitialize) the writer. + * + * @param {string} origin Which of sessionstore.js or its backups + * was used. One of the `STATE_*` constants defined above. + * @param {boolean} a flag indicate whether we loaded a session file with ext .js + * @param {object} paths The paths at which to find the various files. + * @param {object} prefs The preferences the writer needs to know. + */ + init(origin, useOldExtension, paths, prefs) { + if (!(origin in paths || origin == STATE_EMPTY)) { + throw new TypeError("Invalid origin: " + origin); + } + + // Check that all required preference values were passed. + for (let pref of [ + "maxUpgradeBackups", + "maxSerializeBack", + "maxSerializeForward", + ]) { + if (!prefs.hasOwnProperty(pref)) { + throw new TypeError(`Missing preference value for ${pref}`); + } + } + + this.useOldExtension = useOldExtension; + this.state = origin; + this.Paths = paths; + this.maxUpgradeBackups = prefs.maxUpgradeBackups; + this.maxSerializeBack = prefs.maxSerializeBack; + this.maxSerializeForward = prefs.maxSerializeForward; + this.upgradeBackupNeeded = paths.nextUpgradeBackup != paths.upgradeBackup; + return { result: true }; + }, + + /** + * Write the session to disk. + * Write the session to disk, performing any necessary backup + * along the way. + * + * @param {object} state The state to write to disk. + * @param {object} options + * - performShutdownCleanup If |true|, we should + * perform shutdown-time cleanup to ensure that private data + * is not left lying around; + * - isFinalWrite If |true|, write to Paths.clean instead of + * Paths.recovery + */ + async write(state, options) { + let exn; + let telemetry = {}; + + // Cap the number of backward and forward shistory entries on shutdown. + if (options.isFinalWrite) { + for (let window of state.windows) { + for (let tab of window.tabs) { + let lower = 0; + let upper = tab.entries.length; + + if (this.maxSerializeBack > -1) { + lower = Math.max(lower, tab.index - this.maxSerializeBack - 1); + } + if (this.maxSerializeForward > -1) { + upper = Math.min(upper, tab.index + this.maxSerializeForward); + } + + tab.entries = tab.entries.slice(lower, upper); + tab.index -= lower; + } + } + } + + try { + if (this.state == STATE_CLEAN || this.state == STATE_EMPTY) { + // The backups directory may not exist yet. In all other cases, + // we have either already read from or already written to this + // directory, so we are satisfied that it exists. + await IOUtils.makeDirectory(this.Paths.backups); + } + + if (this.state == STATE_CLEAN) { + // Move $Path.clean out of the way, to avoid any ambiguity as + // to which file is more recent. + if (!this.useOldExtension) { + await IOUtils.move(this.Paths.clean, this.Paths.cleanBackup); + } else { + // Since we are migrating from .js to .jsonlz4, + // we need to compress the deprecated $Path.clean + // and write it to $Path.cleanBackup. + let oldCleanPath = this.Paths.clean.replace("jsonlz4", "js"); + let d = await IOUtils.read(oldCleanPath); + await IOUtils.write(this.Paths.cleanBackup, d, { compress: true }); + } + } + + let startWriteMs = Date.now(); + let fileStat; + + if (options.isFinalWrite) { + // We are shutting down. At this stage, we know that + // $Paths.clean is either absent or corrupted. If it was + // originally present and valid, it has been moved to + // $Paths.cleanBackup a long time ago. We can therefore write + // with the guarantees that we erase no important data. + await IOUtils.writeJSON(this.Paths.clean, state, { + tmpPath: this.Paths.clean + ".tmp", + compress: true, + }); + fileStat = await IOUtils.stat(this.Paths.clean); + } else if (this.state == STATE_RECOVERY) { + // At this stage, either $Paths.recovery was written >= 15 + // seconds ago during this session or we have just started + // from $Paths.recovery left from the previous session. Either + // way, $Paths.recovery is good. We can move $Path.backup to + // $Path.recoveryBackup without erasing a good file with a bad + // file. + await IOUtils.writeJSON(this.Paths.recovery, state, { + tmpPath: this.Paths.recovery + ".tmp", + backupFile: this.Paths.recoveryBackup, + compress: true, + }); + fileStat = await IOUtils.stat(this.Paths.recovery); + } else { + // In other cases, either $Path.recovery is not necessary, or + // it doesn't exist or it has been corrupted. Regardless, + // don't backup $Path.recovery. + await IOUtils.writeJSON(this.Paths.recovery, state, { + tmpPath: this.Paths.recovery + ".tmp", + compress: true, + }); + fileStat = await IOUtils.stat(this.Paths.recovery); + } + + telemetry.FX_SESSION_RESTORE_WRITE_FILE_MS = Date.now() - startWriteMs; + telemetry.FX_SESSION_RESTORE_FILE_SIZE_BYTES = fileStat.size; + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } + + // If necessary, perform an upgrade backup + let upgradeBackupComplete = false; + if ( + this.upgradeBackupNeeded && + (this.state == STATE_CLEAN || this.state == STATE_UPGRADE_BACKUP) + ) { + try { + // If we loaded from `clean`, the file has since then been renamed to `cleanBackup`. + let path = + this.state == STATE_CLEAN + ? this.Paths.cleanBackup + : this.Paths.upgradeBackup; + await IOUtils.copy(path, this.Paths.nextUpgradeBackup); + this.upgradeBackupNeeded = false; + upgradeBackupComplete = true; + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } + + // Find all backups + let backups = []; + + try { + let children = await IOUtils.getChildren(this.Paths.backups); + backups = children.filter(path => + path.startsWith(this.Paths.upgradeBackupPrefix) + ); + } catch (ex) { + // Don't throw immediately + exn = exn || ex; + } + + // If too many backups exist, delete them + if (backups.length > this.maxUpgradeBackups) { + // Use alphanumerical sort since dates are in YYYYMMDDHHMMSS format + backups.sort(); + // remove backup file if it is among the first (n-maxUpgradeBackups) files + for (let i = 0; i < backups.length - this.maxUpgradeBackups; i++) { + try { + await IOUtils.remove(backups[i]); + } catch (ex) { + exn = exn || ex; + } + } + } + } + + if (options.performShutdownCleanup && !exn) { + // During shutdown, if auto-restore is disabled, we need to + // remove possibly sensitive data that has been stored purely + // for crash recovery. Note that this slightly decreases our + // ability to recover from OS-level/hardware-level issue. + + // If an exception was raised, we assume that we still need + // these files. + await IOUtils.remove(this.Paths.recoveryBackup); + await IOUtils.remove(this.Paths.recovery); + } + + this.state = STATE_RECOVERY; + + if (exn) { + throw exn; + } + + return { + result: { + upgradeBackup: upgradeBackupComplete, + }, + telemetry, + }; + }, + + /** + * Wipes all files holding session data from disk. + */ + async wipe() { + // Don't stop immediately in case of error. + let exn = null; + + // Erase main session state file + try { + await IOUtils.remove(this.Paths.clean); + // Remove old extension ones. + let oldCleanPath = this.Paths.clean.replace("jsonlz4", "js"); + await IOUtils.remove(oldCleanPath, { + ignoreAbsent: true, + }); + } catch (ex) { + // Don't stop immediately. + exn = exn || ex; + } + + // Wipe the Session Restore directory + try { + await IOUtils.remove(this.Paths.backups, { recursive: true }); + } catch (ex) { + exn = exn || ex; + } + + // Wipe legacy Session Restore files from the profile directory + try { + await this._wipeFromDir(PathUtils.profileDir, "sessionstore.bak"); + } catch (ex) { + exn = exn || ex; + } + + this.state = STATE_EMPTY; + if (exn) { + throw exn; + } + + return { result: true }; + }, + + /** + * Wipe a number of files from a directory. + * + * @param {string} path The directory. + * @param {string} prefix Remove files whose + * name starts with the prefix. + */ + async _wipeFromDir(path, prefix) { + // Sanity check + if (!prefix) { + throw new TypeError("Must supply prefix"); + } + + let exn = null; + + let children = await IOUtils.getChildren(path, { + ignoreAbsent: true, + }); + for (let entryPath of children) { + if (!PathUtils.filename(entryPath).startsWith(prefix)) { + continue; + } + try { + let { type } = await IOUtils.stat(entryPath); + if (type == "directory") { + continue; + } + await IOUtils.remove(entryPath); + } catch (ex) { + // Don't stop immediately + exn = exn || ex; + } + } + + if (exn) { + throw exn; + } + }, +}; diff --git a/browser/components/sessionstore/StartupPerformance.sys.mjs b/browser/components/sessionstore/StartupPerformance.sys.mjs new file mode 100644 index 0000000000..a13333d9d1 --- /dev/null +++ b/browser/components/sessionstore/StartupPerformance.sys.mjs @@ -0,0 +1,242 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const COLLECT_RESULTS_AFTER_MS = 10000; + +const OBSERVED_TOPICS = [ + "sessionstore-restoring-on-startup", + "sessionstore-initiating-manual-restore", +]; + +export var StartupPerformance = { + /** + * Once we have finished restoring initial tabs, we broadcast on this topic. + */ + RESTORED_TOPIC: "sessionstore-finished-restoring-initial-tabs", + + // Instant at which we have started restoration (notification "sessionstore-restoring-on-startup") + _startTimeStamp: null, + + // Latest instant at which we have finished restoring a tab (DOM event "SSTabRestored") + _latestRestoredTimeStamp: null, + + // A promise resolved once we have finished restoring all the startup tabs. + _promiseFinished: null, + + // Function `resolve()` for `_promiseFinished`. + _resolveFinished: null, + + // A timer + _deadlineTimer: null, + + // `true` once the timer has fired + _hasFired: false, + + // `true` once we are restored + _isRestored: false, + + // Statistics on the session we need to restore. + _totalNumberOfEagerTabs: 0, + _totalNumberOfTabs: 0, + _totalNumberOfWindows: 0, + + init() { + for (let topic of OBSERVED_TOPICS) { + Services.obs.addObserver(this, topic); + } + }, + + /** + * Return the timestamp at which we finished restoring the latest tab. + * + * This information is not really interesting until we have finished restoring + * tabs. + */ + get latestRestoredTimeStamp() { + return this._latestRestoredTimeStamp; + }, + + /** + * `true` once we have finished restoring startup tabs. + */ + get isRestored() { + return this._isRestored; + }, + + // Called when restoration starts. + // Record the start timestamp, setup the timer and `this._promiseFinished`. + // Behavior is unspecified if there was already an ongoing measure. + _onRestorationStarts(isAutoRestore) { + ChromeUtils.addProfilerMarker("_onRestorationStarts"); + this._latestRestoredTimeStamp = this._startTimeStamp = Date.now(); + this._totalNumberOfEagerTabs = 0; + this._totalNumberOfTabs = 0; + this._totalNumberOfWindows = 0; + + // While we may restore several sessions in a single run of the browser, + // that's a very unusual case, and not really worth measuring, so let's + // stop listening for further restorations. + + for (let topic of OBSERVED_TOPICS) { + Services.obs.removeObserver(this, topic); + } + + Services.obs.addObserver(this, "sessionstore-single-window-restored"); + this._promiseFinished = new Promise(resolve => { + this._resolveFinished = resolve; + }); + this._promiseFinished.then(() => { + try { + this._isRestored = true; + Services.obs.notifyObservers(null, this.RESTORED_TOPIC); + + if (this._latestRestoredTimeStamp == this._startTimeStamp) { + // Apparently, we haven't restored any tab. + return; + } + + // Once we are done restoring tabs, update Telemetry. + let histogramName = isAutoRestore + ? "FX_SESSION_RESTORE_AUTO_RESTORE_DURATION_UNTIL_EAGER_TABS_RESTORED_MS" + : "FX_SESSION_RESTORE_MANUAL_RESTORE_DURATION_UNTIL_EAGER_TABS_RESTORED_MS"; + let histogram = Services.telemetry.getHistogramById(histogramName); + let delta = this._latestRestoredTimeStamp - this._startTimeStamp; + histogram.add(delta); + + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_NUMBER_OF_EAGER_TABS_RESTORED") + .add(this._totalNumberOfEagerTabs); + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_NUMBER_OF_TABS_RESTORED") + .add(this._totalNumberOfTabs); + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_NUMBER_OF_WINDOWS_RESTORED") + .add(this._totalNumberOfWindows); + + // Reset + this._startTimeStamp = null; + } catch (ex) { + console.error("StartupPerformance: error after resolving promise", ex); + } + }); + }, + + _startTimer() { + if (this._hasFired) { + return; + } + if (this._deadlineTimer) { + lazy.clearTimeout(this._deadlineTimer); + } + this._deadlineTimer = lazy.setTimeout(() => { + try { + this._resolveFinished(); + } catch (ex) { + console.error("StartupPerformance: Error in timeout handler", ex); + } finally { + // Clean up. + this._deadlineTimer = null; + this._hasFired = true; + this._resolveFinished = null; + Services.obs.removeObserver( + this, + "sessionstore-single-window-restored" + ); + } + }, COLLECT_RESULTS_AFTER_MS); + }, + + observe(subject, topic, details) { + try { + switch (topic) { + case "sessionstore-restoring-on-startup": + this._onRestorationStarts(true); + break; + case "sessionstore-initiating-manual-restore": + this._onRestorationStarts(false); + break; + case "sessionstore-single-window-restored": + { + // Session Restore has just opened a window with (initially empty) tabs. + // Some of these tabs will be restored eagerly, while others will be + // restored on demand. The process becomes usable only when all windows + // have finished restored their eager tabs. + // + // While it would be possible to track the restoration of each tab + // from within SessionRestore to determine exactly when the process + // becomes usable, experience shows that this is too invasive. Rather, + // we employ the following heuristic: + // - we maintain a timer of `COLLECT_RESULTS_AFTER_MS` that we expect + // will be triggered only once all tabs have been restored; + // - whenever we restore a new window (hence a bunch of eager tabs), + // we postpone the timer to ensure that the new eager tabs have + // `COLLECT_RESULTS_AFTER_MS` to be restored; + // - whenever a tab is restored, we update + // `this._latestRestoredTimeStamp`; + // - after `COLLECT_RESULTS_AFTER_MS`, we collect the final version + // of `this._latestRestoredTimeStamp`, and use it to determine the + // entire duration of the collection. + // + // Note that this heuristic may be inaccurate if a user clicks + // immediately on a restore-on-demand tab before the end of + // `COLLECT_RESULTS_AFTER_MS`. We assume that this will not + // affect too much the results. + // + // Reset the delay, to give the tabs a little (more) time to restore. + this._startTimer(); + + this._totalNumberOfWindows += 1; + + // Observe the restoration of all tabs. We assume that all tabs of this + // window will have been restored before `COLLECT_RESULTS_AFTER_MS`. + // The last call to `observer` will let us determine how long it took + // to reach that point. + let win = subject; + + let observer = event => { + // We don't care about tab restorations that are due to + // a browser flipping from out-of-main-process to in-main-process + // or vice-versa. We only care about restorations that are due + // to the user switching to a lazily restored tab, or for tabs + // that are restoring eagerly. + if (!event.detail.isRemotenessUpdate) { + ChromeUtils.addProfilerMarker("SSTabRestored"); + this._latestRestoredTimeStamp = Date.now(); + this._totalNumberOfEagerTabs += 1; + } + }; + win.gBrowser.tabContainer.addEventListener( + "SSTabRestored", + observer + ); + this._totalNumberOfTabs += win.gBrowser.tabContainer.itemCount; + + // Once we have finished collecting the results, clean up the observers. + this._promiseFinished.then(() => { + if (!win.gBrowser.tabContainer) { + // May be undefined during shutdown and/or some tests. + return; + } + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + observer + ); + }); + } + break; + default: + throw new Error(`Unexpected topic ${topic}`); + } + } catch (ex) { + console.error("StartupPerformance error", ex, ex.stack); + throw ex; + } + }, +}; diff --git a/browser/components/sessionstore/TabAttributes.sys.mjs b/browser/components/sessionstore/TabAttributes.sys.mjs new file mode 100644 index 0000000000..1c7f54b6ab --- /dev/null +++ b/browser/components/sessionstore/TabAttributes.sys.mjs @@ -0,0 +1,72 @@ +/* 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/. */ + +// We never want to directly read or write these attributes. +// 'image' should not be accessed directly but handled by using the +// gBrowser.getIcon()/setIcon() methods. +// 'muted' should not be accessed directly but handled by using the +// tab.linkedBrowser.audioMuted/toggleMuteAudio methods. +// 'pending' is used internal by sessionstore and managed accordingly. +const ATTRIBUTES_TO_SKIP = new Set([ + "image", + "muted", + "pending", + "skipbackgroundnotify", +]); + +// A set of tab attributes to persist. We will read a given list of tab +// attributes when collecting tab data and will re-set those attributes when +// the given tab data is restored to a new tab. +export var TabAttributes = Object.freeze({ + persist(name) { + return TabAttributesInternal.persist(name); + }, + + get(tab) { + return TabAttributesInternal.get(tab); + }, + + set(tab, data = {}) { + TabAttributesInternal.set(tab, data); + }, +}); + +var TabAttributesInternal = { + _attrs: new Set(), + + persist(name) { + if (this._attrs.has(name) || ATTRIBUTES_TO_SKIP.has(name)) { + return false; + } + + this._attrs.add(name); + return true; + }, + + get(tab) { + let data = {}; + + for (let name of this._attrs) { + if (tab.hasAttribute(name)) { + data[name] = tab.getAttribute(name); + } + } + + return data; + }, + + set(tab, data = {}) { + // Clear attributes. + for (let name of this._attrs) { + tab.removeAttribute(name); + } + + // Set attributes. + for (let [name, value] of Object.entries(data)) { + if (!ATTRIBUTES_TO_SKIP.has(name)) { + tab.setAttribute(name, value); + } + } + }, +}; diff --git a/browser/components/sessionstore/TabState.sys.mjs b/browser/components/sessionstore/TabState.sys.mjs new file mode 100644 index 0000000000..26f5671c84 --- /dev/null +++ b/browser/components/sessionstore/TabState.sys.mjs @@ -0,0 +1,204 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.sys.mjs", + TabAttributes: "resource:///modules/sessionstore/TabAttributes.sys.mjs", + TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", +}); + +/** + * Module that contains tab state collection methods. + */ +export var TabState = Object.freeze({ + update(permanentKey, data) { + TabStateInternal.update(permanentKey, data); + }, + + collect(tab, extData) { + return TabStateInternal.collect(tab, extData); + }, + + clone(tab, extData) { + return TabStateInternal.clone(tab, extData); + }, + + copyFromCache(permanentKey, tabData, options) { + TabStateInternal.copyFromCache(permanentKey, tabData, options); + }, +}); + +var TabStateInternal = { + /** + * Processes a data update sent by the content script. + */ + update(permanentKey, { data }) { + lazy.TabStateCache.update(permanentKey, data); + }, + + /** + * Collect data related to a single tab, synchronously. + * + * @param tab + * tabbrowser tab + * @param [extData] + * optional dictionary object, containing custom tab values. + * + * @returns {TabData} An object with the data for this tab. If the + * tab has not been invalidated since the last call to + * collect(aTab), the same object is returned. + */ + collect(tab, extData) { + return this._collectBaseTabData(tab, { extData }); + }, + + /** + * Collect data related to a single tab, including private data. + * Use with caution. + * + * @param tab + * tabbrowser tab + * @param [extData] + * optional dictionary object, containing custom tab values. + * + * @returns {object} An object with the data for this tab. This data is never + * cached, it will always be read from the tab and thus be + * up-to-date. + */ + clone(tab, extData) { + return this._collectBaseTabData(tab, { extData, includePrivateData: true }); + }, + + /** + * Collects basic tab data for a given tab. + * + * @param tab + * tabbrowser tab + * @param options (object) + * {extData: object} optional dictionary object, containing custom tab values + * {includePrivateData: true} to always include private data + * + * @returns {object} An object with the basic data for this tab. + */ + _collectBaseTabData(tab, options) { + let tabData = { entries: [], lastAccessed: tab.lastAccessed }; + let browser = tab.linkedBrowser; + + if (tab.pinned) { + tabData.pinned = true; + } + + tabData.hidden = tab.hidden; + + if (browser.audioMuted) { + tabData.muted = true; + tabData.muteReason = tab.muteReason; + } + + tabData.searchMode = tab.ownerGlobal.gURLBar.getSearchMode(browser, true); + + tabData.userContextId = tab.userContextId || 0; + + // Save tab attributes. + tabData.attributes = lazy.TabAttributes.get(tab); + + if (options.extData) { + tabData.extData = options.extData; + } + + // Copy data from the tab state cache only if the tab has fully finished + // restoring. We don't want to overwrite data contained in __SS_data. + this.copyFromCache(browser.permanentKey, tabData, options); + + // After copyFromCache() was called we check for properties that are kept + // in the cache only while the tab is pending or restoring. Once that + // happened those properties will be removed from the cache and will + // be read from the tab/browser every time we collect data. + + // Store the tab icon. + if (!("image" in tabData)) { + let tabbrowser = tab.ownerGlobal.gBrowser; + tabData.image = tabbrowser.getIcon(tab); + } + + // If there is a userTypedValue set, then either the user has typed something + // in the URL bar, or a new tab was opened with a URI to load. + // If so, we also track whether we were still in the process of loading something. + if (!("userTypedValue" in tabData) && browser.userTypedValue) { + tabData.userTypedValue = browser.userTypedValue; + // We always used to keep track of the loading state as an integer, where + // '0' indicated the user had typed since the last load (or no load was + // ongoing), and any positive value indicated we had started a load since + // the last time the user typed in the URL bar. Mimic this to keep the + // session store representation in sync, even though we now represent this + // more explicitly: + tabData.userTypedClear = browser.didStartLoadSinceLastUserTyping() + ? 1 + : 0; + } + + return tabData; + }, + + /** + * Copy data for the given |browser| from the cache to |tabData|. + * + * @param permanentKey (object) + * The browser belonging to the given |tabData| object. + * @param tabData (object) + * The tab data belonging to the given |tab|. + * @param options (object) + * {includePrivateData: true} to always include private data + */ + copyFromCache(permanentKey, tabData, options = {}) { + let data = lazy.TabStateCache.get(permanentKey); + if (!data) { + return; + } + + // The caller may explicitly request to omit privacy checks. + let includePrivateData = options && options.includePrivateData; + + for (let key of Object.keys(data)) { + let value = data[key]; + + // Filter sensitive data according to the current privacy level. + if (!includePrivateData) { + if (key === "storage") { + value = lazy.PrivacyFilter.filterSessionStorageData(value); + } else if (key === "formdata") { + value = lazy.PrivacyFilter.filterFormData(value); + } + } + + if (key === "history") { + // Make a shallow copy of the entries array. We (currently) don't update + // entries in place, so we don't have to worry about performing a deep + // copy. + tabData.entries = [...value.entries]; + + if (value.hasOwnProperty("index")) { + tabData.index = value.index; + } + + if (value.hasOwnProperty("requestedIndex")) { + tabData.requestedIndex = value.requestedIndex; + } + } else if (!value && (key == "scroll" || key == "formdata")) { + // [Bug 1554512] + + // If scroll or formdata null it indicates that the update to + // be performed is to remove them, and not copy a null + // value. Scroll will be null when the position is at the top + // of the document, formdata will be null when there is only + // default data. + delete tabData[key]; + } else { + tabData[key] = value; + } + } + }, +}; diff --git a/browser/components/sessionstore/TabStateCache.sys.mjs b/browser/components/sessionstore/TabStateCache.sys.mjs new file mode 100644 index 0000000000..81524c4d69 --- /dev/null +++ b/browser/components/sessionstore/TabStateCache.sys.mjs @@ -0,0 +1,171 @@ +/* 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/. */ + +/** + * A cache for tabs data. + * + * This cache implements a weak map from tabs (as XUL elements) + * to tab data (as objects). + * + * Note that we should never cache private data, as: + * - that data is used very seldom by SessionStore; + * - caching private data in addition to public data is memory consuming. + */ +export var TabStateCache = Object.freeze({ + /** + * Retrieves cached data for a given |tab| or associated |browser|. + * + * @param permanentKey (object) + * The tab or browser to retrieve cached data for. + * @return (object) + * The cached data stored for the given |tab| + * or associated |browser|. + */ + get(permanentKey) { + return TabStateCacheInternal.get(permanentKey); + }, + + /** + * Updates cached data for a given |tab| or associated |browser|. + * + * @param permanentKey (object) + * The tab or browser belonging to the given tab data. + * @param newData (object) + * The new data to be stored for the given |tab| + * or associated |browser|. + */ + update(permanentKey, newData) { + TabStateCacheInternal.update(permanentKey, newData); + }, +}); + +var TabStateCacheInternal = { + _data: new WeakMap(), + + /** + * Retrieves cached data for a given |tab| or associated |browser|. + * + * @param permanentKey (object) + * The tab or browser to retrieve cached data for. + * @return (object) + * The cached data stored for the given |tab| + * or associated |browser|. + */ + get(permanentKey) { + return this._data.get(permanentKey); + }, + + /** + * Helper function used by update (see below). For message size + * optimization sometimes we don't update the whole session storage + * only the values that have been changed. + * + * @param data (object) + * The cached data where we want to update the changes. + * @param change (object) + * The actual changed values per domain. + */ + updatePartialStorageChange(data, change) { + if (!data.storage) { + data.storage = {}; + } + + let storage = data.storage; + for (let domain of Object.keys(change)) { + if (!change[domain]) { + // We were sent null in place of the change object, which means + // we should delete session storage entirely for this domain. + delete storage[domain]; + } else { + for (let key of Object.keys(change[domain])) { + let value = change[domain][key]; + if (value === null) { + if (storage[domain] && storage[domain][key]) { + delete storage[domain][key]; + } + } else { + if (!storage[domain]) { + storage[domain] = {}; + } + storage[domain][key] = value; + } + } + } + } + }, + + /** + * Helper function used by update (see below). For message size + * optimization sometimes we don't update the whole browser history + * only the current index and the tail of the history from a certain + * index (specified by change.fromIdx) + * + * @param data (object) + * The cached data where we want to update the changes. + * @param change (object) + * Object containing the tail of the history array, and + * some additional metadata. + */ + updatePartialHistoryChange(data, change) { + const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + + if (!data.history) { + data.history = { entries: [] }; + } + + let history = data.history; + let toIdx = history.entries.length; + if ("toIdx" in change) { + toIdx = Math.min(toIdx, change.toIdx + 1); + } + + for (let key of Object.keys(change)) { + if (key == "entries") { + if (change.fromIdx != kLastIndex) { + let start = change.fromIdx + 1; + history.entries.splice.apply( + history.entries, + [start, toIdx - start].concat(change.entries) + ); + } + } else if (key != "fromIdx" && key != "toIdx") { + history[key] = change[key]; + } + } + }, + + /** + * Updates cached data for a given |tab| or associated |browser|. + * + * @param permanentKey (object) + * The tab or browser belonging to the given tab data. + * @param newData (object) + * The new data to be stored for the given |tab| + * or associated |browser|. + */ + update(permanentKey, newData) { + let data = this._data.get(permanentKey) || {}; + + for (let key of Object.keys(newData)) { + if (key == "storagechange") { + this.updatePartialStorageChange(data, newData.storagechange); + continue; + } + + if (key == "historychange") { + this.updatePartialHistoryChange(data, newData.historychange); + continue; + } + + let value = newData[key]; + if (value === null) { + delete data[key]; + } else { + data[key] = value; + } + } + + this._data.set(permanentKey, data); + }, +}; diff --git a/browser/components/sessionstore/TabStateFlusher.sys.mjs b/browser/components/sessionstore/TabStateFlusher.sys.mjs new file mode 100644 index 0000000000..897d02b6f6 --- /dev/null +++ b/browser/components/sessionstore/TabStateFlusher.sys.mjs @@ -0,0 +1,234 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +/** + * A module that enables async flushes. Updates from frame scripts are + * throttled to be sent only once per second. If an action wants a tab's latest + * state without waiting for a second then it can request an async flush and + * wait until the frame scripts reported back. At this point the parent has the + * latest data and the action can continue. + */ +export var TabStateFlusher = Object.freeze({ + /** + * Requests an async flush for the given browser. Returns a promise that will + * resolve when we heard back from the content process and the parent has + * all the latest data. + */ + flush(browser) { + return TabStateFlusherInternal.flush(browser); + }, + + /** + * Requests an async flush for all browsers of a given window. Returns a Promise + * that will resolve when we've heard back from all browsers. + */ + flushWindow(window) { + return TabStateFlusherInternal.flushWindow(window); + }, + + /** + * Resolves the flush request with the given flush ID. + * + * @param browser (<xul:browser>) + * The browser for which the flush is being resolved. + * @param flushID (int) + * The ID of the flush that was sent to the browser. + * @param success (bool, optional) + * Whether or not the flush succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that a flush failed. + */ + resolve(browser, flushID, success = true, message = "") { + TabStateFlusherInternal.resolve(browser, flushID, success, message); + }, + + /** + * Resolves all active flush requests for a given browser. This should be + * used when the content process crashed or the final update message was + * seen. In those cases we can't guarantee to ever hear back from the frame + * script so we just resolve all requests instead of discarding them. + * + * @param browser (<xul:browser>) + * The browser for which all flushes are being resolved. + * @param success (bool, optional) + * Whether or not the flushes succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that the flushes failed. + */ + resolveAll(browser, success = true, message = "") { + TabStateFlusherInternal.resolveAll(browser, success, message); + }, +}); + +var TabStateFlusherInternal = { + // Stores the last request ID. + _lastRequestID: 0, + + // A map storing all active requests per browser. A request is a + // triple of a map containing all flush requests, a promise that + // resolve when a request for a browser is canceled, and the + // function to call to cancel a reqeust. + _requests: new WeakMap(), + + initEntry(entry) { + entry.perBrowserRequests = new Map(); + entry.cancelPromise = new Promise(resolve => { + entry.cancel = resolve; + }).then(result => { + TabStateFlusherInternal.initEntry(entry); + return result; + }); + + return entry; + }, + + /** + * Requests an async flush for the given browser. Returns a promise that will + * resolve when we heard back from the content process and the parent has + * all the latest data. + */ + flush(browser) { + let id = ++this._lastRequestID; + let nativePromise = Promise.resolve(); + if (browser && browser.frameLoader) { + /* + Request native listener to flush the tabState. + Resolves when flush is complete. + */ + nativePromise = browser.frameLoader.requestTabStateFlush(); + } + + if (!Services.appinfo.sessionHistoryInParent) { + /* + In the event that we have to trigger a process switch and thus change + browser remoteness, session store needs to register and track the new + browser window loaded and to have message manager listener registered + ** before ** TabStateFlusher send "SessionStore:flush" message. This fixes + the race where we send the message before the message listener is + registered for it. + */ + lazy.SessionStore.ensureInitialized(browser.ownerGlobal); + + let mm = browser.messageManager; + mm.sendAsyncMessage("SessionStore:flush", { + id, + epoch: lazy.SessionStore.getCurrentEpoch(browser), + }); + } + + // Retrieve active requests for given browser. + let permanentKey = browser.permanentKey; + let request = this._requests.get(permanentKey); + if (!request) { + // If we don't have any requests for this browser, create a new + // entry for browser. + request = this.initEntry({}); + this._requests.set(permanentKey, request); + } + + // Non-SHIP flushes resolve this after the "SessionStore:update" message. We + // don't use that message for SHIP, so it's fine to resolve the request + // immediately after the native promise resolves, since SessionStore will + // have processed all updates from this browser by that point. + let requestPromise = Promise.resolve(); + if (!Services.appinfo.sessionHistoryInParent) { + requestPromise = new Promise(resolve => { + // Store resolve() so that we can resolve the promise later. + request.perBrowserRequests.set(id, resolve); + }); + } + + return Promise.race([ + nativePromise.then(_ => requestPromise), + request.cancelPromise, + ]); + }, + + /** + * Requests an async flush for all non-lazy browsers of a given window. + * Returns a Promise that will resolve when we've heard back from all browsers. + */ + flushWindow(window) { + let promises = []; + for (let browser of window.gBrowser.browsers) { + if (window.gBrowser.getTabForBrowser(browser).linkedPanel) { + promises.push(this.flush(browser)); + } + } + return Promise.all(promises); + }, + + /** + * Resolves the flush request with the given flush ID. + * + * @param browser (<xul:browser>) + * The browser for which the flush is being resolved. + * @param flushID (int) + * The ID of the flush that was sent to the browser. + * @param success (bool, optional) + * Whether or not the flush succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that a flush failed. + */ + resolve(browser, flushID, success = true, message = "") { + // Nothing to do if there are no pending flushes for the given browser. + if (!this._requests.has(browser.permanentKey)) { + return; + } + + // Retrieve active requests for given browser. + let { perBrowserRequests } = this._requests.get(browser.permanentKey); + if (!perBrowserRequests.has(flushID)) { + return; + } + + if (!success) { + Cu.reportError("Failed to flush browser: " + message); + } + + // Resolve the request with the given id. + let resolve = perBrowserRequests.get(flushID); + perBrowserRequests.delete(flushID); + resolve(success); + }, + + /** + * Resolves all active flush requests for a given browser. This should be + * used when the content process crashed or the final update message was + * seen. In those cases we can't guarantee to ever hear back from the frame + * script so we just resolve all requests instead of discarding them. + * + * @param browser (<xul:browser>) + * The browser for which all flushes are being resolved. + * @param success (bool, optional) + * Whether or not the flushes succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that the flushes failed. + */ + resolveAll(browser, success = true, message = "") { + // Nothing to do if there are no pending flushes for the given browser. + if (!this._requests.has(browser.permanentKey)) { + return; + } + + // Retrieve the cancel function for a given browser. + let { cancel } = this._requests.get(browser.permanentKey); + + if (!success) { + Cu.reportError("Failed to flush browser: " + message); + } + + // Resolve all requests. + cancel(success); + }, +}; diff --git a/browser/components/sessionstore/content/aboutSessionRestore.js b/browser/components/sessionstore/content/aboutSessionRestore.js new file mode 100644 index 0000000000..321bb9d0db --- /dev/null +++ b/browser/components/sessionstore/content/aboutSessionRestore.js @@ -0,0 +1,460 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +ChromeUtils.defineESModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +var gStateObject; +var gTreeData; +var gTreeInitialized = false; + +// Page initialization + +window.onload = function() { + let toggleTabs = document.getElementById("tabsToggle"); + if (toggleTabs) { + let tabList = document.getElementById("tabList"); + + let toggleHiddenTabs = () => { + toggleTabs.classList.toggle("tabs-hidden"); + tabList.hidden = toggleTabs.classList.contains("tabs-hidden"); + initTreeView(); + }; + toggleTabs.onclick = toggleHiddenTabs; + } + + // pages used by this script may have a link that needs to be updated to + // the in-product link. + let anchor = document.getElementById("linkMoreTroubleshooting"); + if (anchor) { + let baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL"); + anchor.setAttribute("href", baseURL + "troubleshooting"); + } + + // wire up click handlers for the radio buttons if they exist. + for (let radioId of ["radioRestoreAll", "radioRestoreChoose"]) { + let button = document.getElementById(radioId); + if (button) { + button.addEventListener("click", updateTabListVisibility); + } + } + + var tabListTree = document.getElementById("tabList"); + tabListTree.addEventListener("click", onListClick); + tabListTree.addEventListener("keydown", onListKeyDown); + + var errorCancelButton = document.getElementById("errorCancel"); + // aboutSessionRestore.js is included aboutSessionRestore.xhtml + // and aboutWelcomeBack.xhtml, but the latter does not have an + // errorCancel button. + if (errorCancelButton) { + errorCancelButton.addEventListener("command", startNewSession); + } + + var errorTryAgainButton = document.getElementById("errorTryAgain"); + errorTryAgainButton.addEventListener("command", restoreSession); + + // the crashed session state is kept inside a textbox so that SessionStore picks it up + // (for when the tab is closed or the session crashes right again) + var sessionData = document.getElementById("sessionData"); + if (!sessionData.value) { + errorTryAgainButton.disabled = true; + return; + } + + gStateObject = JSON.parse(sessionData.value); + + // make sure the data is tracked to be restored in case of a subsequent crash + var event = document.createEvent("UIEvents"); + event.initUIEvent("input", true, true, window, 0); + sessionData.dispatchEvent(event); + + initTreeView(); + + errorTryAgainButton.focus({ focusVisible: false }); +}; + +function isTreeViewVisible() { + return !document.getElementById("tabList").hidden; +} + +async function initTreeView() { + if (gTreeInitialized || !isTreeViewVisible()) { + return; + } + + var tabList = document.getElementById("tabList"); + let l10nIds = []; + for ( + let labelIndex = 0; + labelIndex < gStateObject.windows.length; + labelIndex++ + ) { + l10nIds.push({ + id: "restore-page-window-label", + args: { windowNumber: labelIndex + 1 }, + }); + } + let winLabels = await document.l10n.formatValues(l10nIds); + gTreeData = []; + gStateObject.windows.forEach(function(aWinData, aIx) { + var winState = { + label: winLabels[aIx], + open: true, + checked: true, + ix: aIx, + }; + winState.tabs = aWinData.tabs.map(function(aTabData) { + var entry = aTabData.entries[aTabData.index - 1] || { + url: "about:blank", + }; + var iconURL = aTabData.image || null; + // don't initiate a connection just to fetch a favicon (see bug 462863) + if (/^https?:/.test(iconURL)) { + iconURL = "moz-anno:favicon:" + iconURL; + } + return { + label: entry.title || entry.url, + checked: true, + src: iconURL, + parent: winState, + }; + }); + gTreeData.push(winState); + for (let tab of winState.tabs) { + gTreeData.push(tab); + } + }, this); + + tabList.view = treeView; + tabList.view.selection.select(0); + gTreeInitialized = true; +} + +// User actions +function updateTabListVisibility() { + document.getElementById("tabList").hidden = !document.getElementById( + "radioRestoreChoose" + ).checked; + initTreeView(); +} + +function restoreSession() { + Services.obs.notifyObservers(null, "sessionstore-initiating-manual-restore"); + document.getElementById("errorTryAgain").disabled = true; + + if (isTreeViewVisible()) { + if (!gTreeData.some(aItem => aItem.checked)) { + // This should only be possible when we have no "cancel" button, and thus + // the "Restore session" button always remains enabled. In that case and + // when nothing is selected, we just want a new session. + startNewSession(); + return; + } + + // remove all unselected tabs from the state before restoring it + var ix = gStateObject.windows.length - 1; + for (var t = gTreeData.length - 1; t >= 0; t--) { + if (treeView.isContainer(t)) { + if (gTreeData[t].checked === 0) { + // this window will be restored partially + gStateObject.windows[ix].tabs = gStateObject.windows[ix].tabs.filter( + (aTabData, aIx) => gTreeData[t].tabs[aIx].checked + ); + } else if (!gTreeData[t].checked) { + // this window won't be restored at all + gStateObject.windows.splice(ix, 1); + } + ix--; + } + } + } + var stateString = JSON.stringify(gStateObject); + + var top = getBrowserWindow(); + + // if there's only this page open, reuse the window for restoring the session + if (top.gBrowser.tabs.length == 1) { + SessionStore.setWindowState(top, stateString, true); + return; + } + + // restore the session into a new window and close the current tab + var newWindow = top.openDialog( + top.location, + "_blank", + "chrome,dialog=no,all" + ); + + Services.obs.addObserver(function observe(win, topic) { + if (win != newWindow) { + return; + } + + Services.obs.removeObserver(observe, topic); + SessionStore.setWindowState(newWindow, stateString, true); + + let tabbrowser = top.gBrowser; + let browser = window.docShell.chromeEventHandler; + let tab = tabbrowser.getTabForBrowser(browser); + tabbrowser.removeTab(tab); + }, "browser-delayed-startup-finished"); +} + +function startNewSession() { + if (Services.prefs.getIntPref("browser.startup.page") == 0) { + getBrowserWindow().gBrowser.loadURI("about:blank", { + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( + {} + ), + }); + } else { + getBrowserWindow().BrowserHome(); + } +} + +function onListClick(aEvent) { + // don't react to right-clicks + if (aEvent.button == 2) { + return; + } + + var cell = treeView.treeBox.getCellAt(aEvent.clientX, aEvent.clientY); + if (cell.col) { + // Restore this specific tab in the same window for middle/double/accel clicking + // on a tab's title. + let accelKey = + AppConstants.platform == "macosx" ? aEvent.metaKey : aEvent.ctrlKey; + if ( + (aEvent.button == 1 || + (aEvent.button == 0 && aEvent.detail == 2) || + accelKey) && + cell.col.id == "title" && + !treeView.isContainer(cell.row) + ) { + restoreSingleTab(cell.row, aEvent.shiftKey); + aEvent.stopPropagation(); + } else if (cell.col.id == "restore") { + toggleRowChecked(cell.row); + } + } +} + +function onListKeyDown(aEvent) { + switch (aEvent.keyCode) { + case KeyEvent.DOM_VK_SPACE: + toggleRowChecked(document.getElementById("tabList").currentIndex); + // Prevent page from scrolling on the space key. + aEvent.preventDefault(); + break; + case KeyEvent.DOM_VK_RETURN: + var ix = document.getElementById("tabList").currentIndex; + if (aEvent.ctrlKey && !treeView.isContainer(ix)) { + restoreSingleTab(ix, aEvent.shiftKey); + } + break; + } +} + +// Helper functions + +function getBrowserWindow() { + return window.browsingContext.topChromeWindow; +} + +function toggleRowChecked(aIx) { + function isChecked(aItem) { + return aItem.checked; + } + + var item = gTreeData[aIx]; + item.checked = !item.checked; + treeView.treeBox.invalidateRow(aIx); + + if (treeView.isContainer(aIx)) { + // (un)check all tabs of this window as well + for (let tab of item.tabs) { + tab.checked = item.checked; + treeView.treeBox.invalidateRow(gTreeData.indexOf(tab)); + } + } else { + // Update the window's checkmark as well (0 means "partially checked"). + let state = false; + if (item.parent.tabs.every(isChecked)) { + state = true; + } else if (item.parent.tabs.some(isChecked)) { + state = 0; + } + item.parent.checked = state; + + treeView.treeBox.invalidateRow(gTreeData.indexOf(item.parent)); + } + + // we only disable the button when there's no cancel button. + if (document.getElementById("errorCancel")) { + document.getElementById("errorTryAgain").disabled = !gTreeData.some( + isChecked + ); + } +} + +function restoreSingleTab(aIx, aShifted) { + var tabbrowser = getBrowserWindow().gBrowser; + var newTab = tabbrowser.addWebTab(); + var item = gTreeData[aIx]; + + var tabState = + gStateObject.windows[item.parent.ix].tabs[ + aIx - gTreeData.indexOf(item.parent) - 1 + ]; + // ensure tab would be visible on the tabstrip. + tabState.hidden = false; + SessionStore.setTabState(newTab, JSON.stringify(tabState)); + + // respect the preference as to whether to select the tab (the Shift key inverses) + if ( + Services.prefs.getBoolPref("browser.tabs.loadInBackground") != !aShifted + ) { + tabbrowser.selectedTab = newTab; + } +} + +// Tree controller + +var treeView = { + treeBox: null, + selection: null, + + get rowCount() { + return gTreeData.length; + }, + setTree(treeBox) { + this.treeBox = treeBox; + }, + getCellText(idx, column) { + return gTreeData[idx].label; + }, + isContainer(idx) { + return "open" in gTreeData[idx]; + }, + getCellValue(idx, column) { + return gTreeData[idx].checked; + }, + isContainerOpen(idx) { + return gTreeData[idx].open; + }, + isContainerEmpty(idx) { + return false; + }, + isSeparator(idx) { + return false; + }, + isSorted() { + return false; + }, + isEditable(idx, column) { + return false; + }, + canDrop(idx, orientation, dt) { + return false; + }, + getLevel(idx) { + return this.isContainer(idx) ? 0 : 1; + }, + + getParentIndex(idx) { + if (!this.isContainer(idx)) { + for (var t = idx - 1; t >= 0; t--) { + if (this.isContainer(t)) { + return t; + } + } + } + return -1; + }, + + hasNextSibling(idx, after) { + var thisLevel = this.getLevel(idx); + for (var t = after + 1; t < gTreeData.length; t++) { + if (this.getLevel(t) <= thisLevel) { + return this.getLevel(t) == thisLevel; + } + } + return false; + }, + + toggleOpenState(idx) { + if (!this.isContainer(idx)) { + return; + } + var item = gTreeData[idx]; + if (item.open) { + // remove this window's tab rows from the view + var thisLevel = this.getLevel(idx); + /* eslint-disable no-empty */ + for ( + var t = idx + 1; + t < gTreeData.length && this.getLevel(t) > thisLevel; + t++ + ) {} + /* eslint-disable no-empty */ + var deletecount = t - idx - 1; + gTreeData.splice(idx + 1, deletecount); + this.treeBox.rowCountChanged(idx + 1, -deletecount); + } else { + // add this window's tab rows to the view + var toinsert = gTreeData[idx].tabs; + for (var i = 0; i < toinsert.length; i++) { + gTreeData.splice(idx + i + 1, 0, toinsert[i]); + } + this.treeBox.rowCountChanged(idx + 1, toinsert.length); + } + item.open = !item.open; + this.treeBox.invalidateRow(idx); + }, + + getCellProperties(idx, column) { + if ( + column.id == "restore" && + this.isContainer(idx) && + gTreeData[idx].checked === 0 + ) { + return "partial"; + } + if (column.id == "title") { + return this.getImageSrc(idx, column) ? "icon" : "noicon"; + } + + return ""; + }, + + getRowProperties(idx) { + var winState = gTreeData[idx].parent || gTreeData[idx]; + if (winState.ix % 2 != 0) { + return "alternate"; + } + + return ""; + }, + + getImageSrc(idx, column) { + if (column.id == "title") { + return gTreeData[idx].src || null; + } + return null; + }, + + cycleHeader(column) {}, + cycleCell(idx, column) {}, + selectionChanged() {}, + getColumnProperties(column) { + return ""; + }, +}; diff --git a/browser/components/sessionstore/content/aboutSessionRestore.xhtml b/browser/components/sessionstore/content/aboutSessionRestore.xhtml new file mode 100644 index 0000000000..05538be5d9 --- /dev/null +++ b/browser/components/sessionstore/content/aboutSessionRestore.xhtml @@ -0,0 +1,69 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +# 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/. +--> +<!DOCTYPE html [ + <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> + %htmlDTD; +]> + +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <head> + <meta http-equiv="Content-Security-Policy" content="default-src chrome: resource:; img-src chrome: resource: data:; object-src 'none'" /> + <meta name="color-scheme" content="light dark"/> + <title data-l10n-id="restore-page-tab-title"></title> + <link rel="stylesheet" href="chrome://global/skin/in-content/info-pages.css" type="text/css" media="all"/> + <link rel="stylesheet" href="chrome://browser/skin/aboutSessionRestore.css" type="text/css" media="all"/> + <link rel="icon" href="chrome://global/skin/icons/info.svg"/> + <link rel="localization" href="browser/aboutSessionRestore.ftl"/> + <link rel="localization" href="branding/brand.ftl"/> + <script src="chrome://browser/content/aboutSessionRestore.js"/> + </head> + + <body> + + <div class="container tab-list-tree-container"> + <div class="description-wrapper"> + <div class="title"> + <h1 class="title-text" data-l10n-id="restore-page-error-title"></h1> + </div> + <div class="description"> + <p data-l10n-id="restore-page-problem-desc"></p> + <p data-l10n-id="restore-page-try-this"></p> + </div> + <button id="tabsToggle" class="tabs-hidden"> + <span id="showTabs" data-l10n-id="restore-page-show-tabs"></span> + <span id="hideTabs" data-l10n-id="restore-page-hide-tabs"></span> + </button> + </div> + <xul:tree id="tabList" seltype="single" hidecolumnpicker="true" hidden="true"> + <xul:treecols> + <xul:treecol cycler="true" id="restore" type="checkbox" data-l10n-id="restore-page-restore-header"/> + <xul:splitter class="tree-splitter"/> + <xul:treecol primary="true" id="title" data-l10n-id="restore-page-list-header" flex="1"/> + </xul:treecols> + <xul:treechildren flex="1"/> + </xul:tree> + <div class="button-container"> +#ifdef XP_UNIX + <xul:button id="errorCancel" + data-l10n-id="restore-page-close-button"/> + <xul:button class="primary" + id="errorTryAgain" + data-l10n-id="restore-page-try-again-button"/> +#else + <xul:button class="primary" + id="errorTryAgain" + data-l10n-id="restore-page-try-again-button"/> + <xul:button id="errorCancel" + data-l10n-id="restore-page-close-button"/> +#endif + </div> + <!-- holds the session data for when the tab is closed --> + <input type="text" id="sessionData" hidden="true"/> + </div> + + </body> +</html> diff --git a/browser/components/sessionstore/content/content-sessionStore.js b/browser/components/sessionstore/content/content-sessionStore.js new file mode 100644 index 0000000000..a4bdea0bdc --- /dev/null +++ b/browser/components/sessionstore/content/content-sessionStore.js @@ -0,0 +1,13 @@ +/* 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/. */ + +/* eslint-env mozilla/frame-script */ + +"use strict"; + +const { ContentSessionStore } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/ContentSessionStore.sys.mjs" +); + +void new ContentSessionStore(this); diff --git a/browser/components/sessionstore/jar.mn b/browser/components/sessionstore/jar.mn new file mode 100644 index 0000000000..7e5bc07dc6 --- /dev/null +++ b/browser/components/sessionstore/jar.mn @@ -0,0 +1,8 @@ +# 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.jar: +* content/browser/aboutSessionRestore.xhtml (content/aboutSessionRestore.xhtml) + content/browser/aboutSessionRestore.js (content/aboutSessionRestore.js) + content/browser/content-sessionStore.js (content/content-sessionStore.js) diff --git a/browser/components/sessionstore/moz.build b/browser/components/sessionstore/moz.build new file mode 100644 index 0000000000..d2191375a9 --- /dev/null +++ b/browser/components/sessionstore/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/. + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"] +BROWSER_CHROME_MANIFESTS += ["test/browser.ini"] + +JAR_MANIFESTS += ["jar.mn"] + +EXTRA_JS_MODULES.sessionstore = [ + "ContentRestore.sys.mjs", + "ContentSessionStore.sys.mjs", + "GlobalState.sys.mjs", + "RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs", + "RunState.sys.mjs", + "SessionCookies.sys.mjs", + "SessionFile.sys.mjs", + "SessionMigration.sys.mjs", + "SessionSaver.sys.mjs", + "SessionStartup.sys.mjs", + "SessionStore.sys.mjs", + "SessionWriter.sys.mjs", + "StartupPerformance.sys.mjs", + "TabAttributes.sys.mjs", + "TabState.sys.mjs", + "TabStateCache.sys.mjs", + "TabStateFlusher.sys.mjs", +] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Session Restore") diff --git a/browser/components/sessionstore/test/browser.ini b/browser/components/sessionstore/test/browser.ini new file mode 100644 index 0000000000..c4b7e8610e --- /dev/null +++ b/browser/components/sessionstore/test/browser.ini @@ -0,0 +1,387 @@ +# 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_506482.js is disabled because of frequent failures (bug 538672) +# browser_526613.js is disabled because of frequent failures (bug 534489) +# browser_589246.js is disabled for leaking browser windows (bug 752467) +# browser_580512.js is disabled for leaking browser windows (bug 752467) + +[DEFAULT] +support-files = + head.js + browser_formdata_sample.html + browser_formdata_xpath_sample.html + browser_frametree_sample.html + browser_frametree_sample_frameset.html + browser_frametree_sample_iframes.html + browser_frame_history_index.html + browser_frame_history_index2.html + browser_frame_history_index_blank.html + browser_frame_history_a.html + browser_frame_history_b.html + browser_frame_history_c.html + browser_frame_history_c1.html + browser_frame_history_c2.html + browser_formdata_format_sample.html + browser_sessionHistory_slow.sjs + browser_scrollPositions_sample.html + browser_scrollPositions_sample2.html + browser_scrollPositions_sample_frameset.html + browser_scrollPositions_readerModeArticle.html + browser_sessionStorage.html + browser_speculative_connect.html + browser_248970_b_sample.html + browser_339445_sample.html + browser_423132_sample.html + browser_447951_sample.html + browser_454908_sample.html + browser_456342_sample.xhtml + browser_463205_sample.html + browser_463206_sample.html + browser_466937_sample.html + browser_485482_sample.html + browser_637020_slow.sjs + browser_662743_sample.html + browser_739531_sample.html + browser_739531_frame.html + browser_911547_sample.html + browser_911547_sample.html^headers^ + coopHeaderCommon.sjs + restore_redirect_http.html + restore_redirect_http.html^headers^ + restore_redirect_js.html + restore_redirect_target.html + browser_1234021_page.html + browser_1284886_suspend_tab.html + browser_1284886_suspend_tab_2.html + empty.html + coop_coep.html + coop_coep.html^headers^ +# remove this after bug 1628486 is landed +prefs = + network.cookie.cookieBehavior=5 + gfx.font_rendering.fallback.async=false + +#NB: the following are disabled +# browser_464620_a.html +# browser_464620_b.html +# browser_464620_xd.html + +#disabled-for-intermittent-failures--bug-766044, browser_459906_empty.html +#disabled-for-intermittent-failures--bug-766044, browser_459906_sample.html +#disabled-for-intermittent-failures--bug-765389, browser_461743_sample.html + +[browser_aboutPrivateBrowsing.js] +[browser_aboutSessionRestore.js] +skip-if = + verify && debug && os == "win" + verify && debug && os == "mac" +[browser_async_duplicate_tab.js] +support-files = file_async_duplicate_tab.html +[browser_async_flushes.js] +support-files = file_async_flushes.html +run-if = crashreporter +[browser_async_remove_tab.js] +skip-if = !sessionHistoryInParent +[browser_attributes.js] +[browser_backup_recovery.js] +https_first_disabled = true +skip-if = + verify && debug && os == "linux" +[browser_broadcast.js] +https_first_disabled = true +[browser_capabilities.js] +[browser_cleaner.js] +[browser_closedId.js] +[browser_crashedTabs.js] +https_first_disabled = true +skip-if = + !crashreporter + verify + win10_2004 # high frequency intermittent, Bug 1684120 - timed out + os == "mac" # high frequency intermittent +[browser_firefoxView_restore.js] +[browser_formdata_max_size.js] +[browser_multiple_select_after_load.js] +[browser_pinned_tabs.js] +skip-if = + debug + ccov # Bug 1625525 +[browser_restore_srcdoc.js] +[browser_restore_tabless_window.js] +[browser_restored_window_features.js] +[browser_unrestored_crashedTabs.js] +skip-if = + !crashreporter +[browser_revive_crashed_bg_tabs.js] +https_first_disabled = true +skip-if = + !crashreporter +[browser_dying_cache.js] +skip-if = (os == "win") # bug 1331853 +[browser_dynamic_frames.js] +[browser_firefoxView_selected_restore.js] +[browser_formdata.js] +skip-if = + verify && debug +[browser_formdata_cc.js] +[browser_formdata_format.js] +skip-if = !debug && (os == "linux") # Bug 1535645 +[browser_formdata_password.js] +support-files = file_formdata_password.html +[browser_formdata_xpath.js] +[browser_frametree.js] +https_first_disabled = true +[browser_frame_history.js] +[browser_global_store.js] +[browser_history_persist.js] +[browser_label_and_icon.js] +https_first_disabled = true +skip-if = + apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + apple_catalina && !debug # Bug 1638958 + win10_2004 && bits == 64 && !debug # Bug 1638958 + os == "linux" && !debug # Bug 1638958 + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_merge_closed_tabs.js] +[browser_old_favicon.js] +https_first_disabled = true +[browser_page_title.js] +[browser_pending_tabs.js] +[browser_privatetabs.js] +[browser_purge_shistory.js] +skip-if = !sessionHistoryInParent # Bug 1271024 +[browser_replace_load.js] +skip-if = true # Bug 1646894 +[browser_restore_redirect.js] +https_first_disabled = true +[browser_restoreTabContainer.js] +[browser_restore_cookies_noOriginAttributes.js] +[browser_scrollPositions.js] +https_first_disabled = true +skip-if = + !fission + os == "linux" # Bug 1716445 +[browser_scrollPositionsReaderMode.js] +[browser_sessionHistory.js] +https_first_disabled = true +support-files = + file_sessionHistory_hashchange.html +[browser_sessionStorage.js] +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_sessionStorage_size.js] +[browser_sizemodeBeforeMinimized.js] +[browser_tab_label_during_restore.js] +https_first_disabled = true +[browser_swapDocShells.js] +[browser_switch_remoteness.js] +[browser_upgrade_backup.js] +skip-if = + debug + asan + tsan + verify && debug && os == "mac" # Bug 1435394 disabled on Linux, OSX and Windows +[browser_windowRestore_perwindowpb.js] +[browser_248970_b_perwindowpb.js] +# Disabled because of leaks. +# Re-enabling and rewriting this test is tracked in bug 936919. +skip-if = true +[browser_339445.js] +[browser_345898.js] +[browser_350525.js] +[browser_354894_perwindowpb.js] +[browser_367052.js] +[browser_393716.js] +skip-if = debug # Bug 1507747 +[browser_394759_basic.js] +# Disabled for intermittent failures, bug 944372. +skip-if = true +[browser_394759_behavior.js] +https_first_disabled = true +[browser_394759_perwindowpb.js] +[browser_394759_purge.js] +[browser_423132.js] +[browser_447951.js] +[browser_454908.js] +[browser_456342.js] +[browser_461634.js] +[browser_463205.js] +[browser_463206.js] +[browser_464199.js] +[browser_465215.js] +[browser_465223.js] +[browser_466937.js] +[browser_467409-backslashplosion.js] +[browser_477657.js] +skip-if = os == "linux" && os_version == '18.04' # bug 1610668 for ubuntu 18.04 +[browser_480893.js] +[browser_485482.js] +[browser_485563.js] +[browser_490040.js] +[browser_491168.js] +[browser_491577.js] +skip-if = + verify && debug && os == "mac" + verify && debug && os == "win" +[browser_495495.js] +[browser_500328.js] +[browser_514751.js] +[browser_522375.js] +[browser_522545.js] +skip-if = true # Bug 1380968 +[browser_524745.js] +skip-if = + (os == "win" && os_version == "10.0" && !ccov) # Bug 1418627 + os == "linux" # Bug 1803187 +[browser_528776.js] +[browser_579868.js] +[browser_579879.js] +skip-if = (os == "linux" && (debug||asan)) # Bug 1234404 +[browser_581937.js] +[browser_586147.js] +[browser_586068-apptabs.js] +[browser_586068-apptabs_ondemand.js] +skip-if = (verify && (os == "mac" || os == "win")) +[browser_586068-browser_state_interrupted.js] +[browser_586068-cascade.js] +[browser_586068-multi_window.js] +[browser_586068-reload.js] +https_first_disabled = true +[browser_586068-select.js] +[browser_586068-window_state.js] +[browser_586068-window_state_override.js] +[browser_588426.js] +[browser_590268.js] +[browser_590563.js] +[browser_595601-restore_hidden.js] +[browser_597071.js] +skip-if = true # Needs to be rewritten as Marionette test, bug 995916 +[browser_600545.js] +[browser_601955.js] +[browser_607016.js] +[browser_615394-SSWindowState_events_duplicateTab.js] +[browser_615394-SSWindowState_events_setBrowserState.js] +skip-if = + verify && debug && os == "mac" +[browser_615394-SSWindowState_events_setTabState.js] +[browser_615394-SSWindowState_events_setWindowState.js] +https_first_disabled = true +[browser_615394-SSWindowState_events_undoCloseTab.js] +[browser_615394-SSWindowState_events_undoCloseWindow.js] +skip-if = + os == "win" && !debug # Bug 1572554 + os == "linux" # Bug 1572554 +[browser_618151.js] +[browser_623779.js] +[browser_624727.js] +[browser_628270.js] +[browser_635418.js] +[browser_636279.js] +[browser_637020.js] +[browser_645428.js] +[browser_659591.js] +[browser_662743.js] +[browser_662812.js] +skip-if = verify +[browser_665702-state_session.js] +[browser_682507.js] +[browser_687710.js] +[browser_687710_2.js] +https_first_disabled = true +[browser_694378.js] +[browser_701377.js] +skip-if = + verify && debug && os == "win" + verify && debug && os == "mac" +[browser_705597.js] +[browser_707862.js] +[browser_739531.js] +[browser_739805.js] +[browser_819510_perwindowpb.js] +skip-if = true # Bug 1284312, Bug 1341980, bug 1381451 +[browser_not_collect_when_idle.js] + +# Disabled for frequent intermittent failures +[browser_464620_a.js] +skip-if = true +[browser_464620_b.js] +skip-if = true + +[browser_625016.js] +skip-if = + os == "mac" # Disabled on OS X: + os == "linux" # linux, Bug 1348583 + os == "win" && debug # Bug 1430977 + +[browser_906076_lazy_tabs.js] +https_first_disabled = true +skip-if = os == "linux" && os_version == "18.04" # bug 1446464 +[browser_911547.js] +[browser_1284886_suspend_tab.js] +[browser_async_window_flushing.js] +https_first_disabled = true +[browser_focus_after_restore.js] +[browser_forget_async_closings.js] +https_first_disabled = true +[browser_movePendingTabToNewWindow.js] +https_first_disabled = true +[browser_multiple_navigateAndRestore.js] +skip-if = os == "linux" && debug #Bug 1570468 +[browser_newtab_userTypedValue.js] +skip-if = verify && debug +[browser_parentProcessRestoreHash.js] +https_first_disabled = true +tags = openUILinkIn +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[browser_send_async_message_oom.js] +skip-if = fission # Tests that the frame script OOMs, which is unused in Fission. +[browser_sessionStoreContainer.js] +[browser_windowStateContainer.js] +[browser_1234021.js] +[browser_remoteness_flip_on_restore.js] +[browser_background_tab_crash.js] +https_first_disabled = true +run-if = crashreporter +# Disabled on debug for frequent intermittent failures: +[browser_undoCloseById.js] +skip-if = debug +[browser_docshell_uuid_consistency.js] + +[browser_closed_objects_changed_notifications_tabs.js] +[browser_closed_objects_changed_notifications_windows.js] +[browser_duplicate_history.js] +[browser_tabicon_after_bg_tab_crash.js] +skip-if = + !crashreporter +[browser_tabs_in_urlbar.js] +https_first_disabled = true + +[browser_cookies.js] +[browser_cookies_legacy.js] +[browser_cookies_privacy.js] +[browser_speculative_connect.js] +[browser_1446343-windowsize.js] +skip-if = os == "linux" # Bug 1600180 +[browser_restore_reversed_z_order.js] +skip-if = true #Bug 1455602 +[browser_cookies_sameSite.js] + +[browser_urlbarSearchMode.js] +[browser_restore_container_tabs_oa.js] +[browser_restore_private_tab_os.js] +[browser_reopen_all_windows.js] +https_first_disabled = true +[browser_ignore_updates_crashed_tabs.js] +https_first_disabled = true +run-if = crashreporter +skip-if = + asan + os == "win" && fission && verify # bug 1709907 + os == "mac" && fission # Bug 1711008; high frequency intermittent +[browser_bfcache_telemetry.js] +[browser_userTyped_restored_after_discard.js] +[browser_restore_pageProxyState.js] +[browser_duplicate_tab_in_new_window.js] diff --git a/browser/components/sessionstore/test/browser_1234021.js b/browser/components/sessionstore/test/browser_1234021.js new file mode 100644 index 0000000000..f6a95ad68d --- /dev/null +++ b/browser/components/sessionstore/test/browser_1234021.js @@ -0,0 +1,22 @@ +"use strict"; + +const PREF = "network.cookie.cookieBehavior"; +const PAGE_URL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_1234021_page.html"; +const BEHAVIOR_REJECT = 2; + +add_task(async function test() { + await pushPrefs([PREF, BEHAVIOR_REJECT]); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE_URL, + }, + async function handler(aBrowser) { + await TabStateFlusher.flush(aBrowser); + ok(true, "Flush didn't time out"); + } + ); +}); diff --git a/browser/components/sessionstore/test/browser_1234021_page.html b/browser/components/sessionstore/test/browser_1234021_page.html new file mode 100644 index 0000000000..0c3fca84db --- /dev/null +++ b/browser/components/sessionstore/test/browser_1234021_page.html @@ -0,0 +1,6 @@ +<!doctype html> +<html> + <script> + sessionStorage; + </script> +</html> diff --git a/browser/components/sessionstore/test/browser_1284886_suspend_tab.html b/browser/components/sessionstore/test/browser_1284886_suspend_tab.html new file mode 100644 index 0000000000..ec3edbffdc --- /dev/null +++ b/browser/components/sessionstore/test/browser_1284886_suspend_tab.html @@ -0,0 +1,12 @@ +<html> +<head> + <script> + window.onbeforeunload = function() { + return true; + }; + </script> +</head> +<body> +TEST PAGE +</body> +</html> diff --git a/browser/components/sessionstore/test/browser_1284886_suspend_tab.js b/browser/components/sessionstore/test/browser_1284886_suspend_tab.js new file mode 100644 index 0000000000..3c6a2c1f2c --- /dev/null +++ b/browser/components/sessionstore/test/browser_1284886_suspend_tab.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.require_user_interaction_for_beforeunload", false]], + }); + + let url = "about:robots"; + let tab0 = gBrowser.tabs[0]; + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + const staleAttributes = [ + "activemedia-blocked", + "busy", + "pendingicon", + "progress", + "soundplaying", + ]; + for (let attr of staleAttributes) { + tab0.toggleAttribute(attr, true); + } + gBrowser.discardBrowser(tab0); + ok(!tab0.linkedPanel, "tab0 is suspended"); + for (let attr of staleAttributes) { + ok( + !tab0.hasAttribute(attr), + `discarding browser removes "${attr}" tab attribute` + ); + } + + await BrowserTestUtils.switchTab(gBrowser, tab0); + ok(tab0.linkedPanel, "selecting tab unsuspends it"); + + // Test that active tab is not able to be suspended. + gBrowser.discardBrowser(tab0); + ok(tab0.linkedPanel, "active tab is not able to be suspended"); + + // Test that tab that is closing is not able to be suspended. + gBrowser._beginRemoveTab(tab1); + gBrowser.discardBrowser(tab1); + + ok(tab1.linkedPanel, "cannot suspend a tab that is closing"); + + gBrowser._endRemoveTab(tab1); + + // Open tab containing a page which has a beforeunload handler which shows a prompt. + url = + "http://example.com/browser/browser/components/sessionstore/test/browser_1284886_suspend_tab.html"; + tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await BrowserTestUtils.switchTab(gBrowser, tab0); + + // Test that tab with beforeunload handler which would show a prompt cannot be suspended. + gBrowser.discardBrowser(tab1); + ok( + tab1.linkedPanel, + "cannot suspend a tab with beforeunload handler which would show a prompt" + ); + + // Test that tab with beforeunload handler which would show a prompt will be suspended if forced. + gBrowser.discardBrowser(tab1, true); + ok( + !tab1.linkedPanel, + "force suspending a tab with beforeunload handler which would show a prompt" + ); + + BrowserTestUtils.removeTab(tab1); + + // Open tab containing a page which has a beforeunload handler which does not show a prompt. + url = + "http://example.com/browser/browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html"; + tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await BrowserTestUtils.switchTab(gBrowser, tab0); + + // Test that tab with beforeunload handler which would not show a prompt can be suspended. + gBrowser.discardBrowser(tab1); + ok( + !tab1.linkedPanel, + "can suspend a tab with beforeunload handler which would not show a prompt" + ); + + BrowserTestUtils.removeTab(tab1); + + // Test that non-remote tab is not able to be suspended. + url = "about:robots"; + tab1 = BrowserTestUtils.addTab(gBrowser, url, { forceNotRemote: true }); + await promiseBrowserLoaded(tab1.linkedBrowser, true, url); + await BrowserTestUtils.switchTab(gBrowser, tab1); + await BrowserTestUtils.switchTab(gBrowser, tab0); + + gBrowser.discardBrowser(tab1); + ok(tab1.linkedPanel, "cannot suspend a remote tab"); + + BrowserTestUtils.removeTab(tab1); +}); diff --git a/browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html b/browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html new file mode 100644 index 0000000000..5c42913635 --- /dev/null +++ b/browser/components/sessionstore/test/browser_1284886_suspend_tab_2.html @@ -0,0 +1,11 @@ +<html> +<head> + <script> + window.onbeforeunload = function() { + }; + </script> +</head> +<body> +TEST PAGE +</body> +</html> diff --git a/browser/components/sessionstore/test/browser_1446343-windowsize.js b/browser/components/sessionstore/test/browser_1446343-windowsize.js new file mode 100644 index 0000000000..97f664a460 --- /dev/null +++ b/browser/components/sessionstore/test/browser_1446343-windowsize.js @@ -0,0 +1,39 @@ +add_task(async function test() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + + async function changeSizeMode(mode) { + let promise = BrowserTestUtils.waitForEvent(win, "sizemodechange"); + win[mode](); + await promise; + } + if (win.windowState != win.STATE_NORMAL) { + await changeSizeMode("restore"); + } + + const { outerWidth, outerHeight, screenX, screenY } = win; + function checkCurrentState(sizemode) { + let state = ss.getWindowState(win); + let winState = state.windows[0]; + let msgSuffix = ` should match on ${sizemode} mode`; + is(winState.width, outerWidth, "width" + msgSuffix); + is(winState.height, outerHeight, "height" + msgSuffix); + // The position attributes seem to be affected on macOS when the + // window gets maximized, so skip checking them for now. + if (AppConstants.platform != "macosx" || sizemode == "normal") { + is(winState.screenX, screenX, "screenX" + msgSuffix); + is(winState.screenY, screenY, "screenY" + msgSuffix); + } + is(winState.sizemode, sizemode, "sizemode should match"); + } + + checkCurrentState("normal"); + + await changeSizeMode("maximize"); + checkCurrentState("maximized"); + + await changeSizeMode("minimize"); + checkCurrentState("minimized"); + + // Clean up. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_248970_b_perwindowpb.js b/browser/components/sessionstore/test/browser_248970_b_perwindowpb.js new file mode 100644 index 0000000000..872f59c4d4 --- /dev/null +++ b/browser/components/sessionstore/test/browser_248970_b_perwindowpb.js @@ -0,0 +1,198 @@ +/* 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/. */ + +function test() { + /** Test (B) for Bug 248970 **/ + waitForExplicitFinish(); + + let windowsToClose = []; + let file = Services.dirsvc.get("TmpD", Ci.nsIFile); + let filePath = file.path; + let fieldList = { + "//input[@name='input']": Date.now().toString(16), + "//input[@name='spaced 1']": Math.random().toString(), + "//input[3]": "three", + "//input[@type='checkbox']": true, + "//input[@name='uncheck']": false, + "//input[@type='radio'][1]": false, + "//input[@type='radio'][2]": true, + "//input[@type='radio'][3]": false, + "//select": 2, + "//select[@multiple]": [1, 3], + "//textarea[1]": "", + "//textarea[2]": "Some text... " + Math.random(), + "//textarea[3]": "Some more text\n" + new Date(), + "//input[@type='file']": filePath, + }; + + registerCleanupFunction(async function() { + for (let win of windowsToClose) { + await BrowserTestUtils.closeWindow(win); + } + }); + + function checkNoThrow(aLambda) { + try { + return aLambda() || true; + } catch (ex) {} + return false; + } + + function getElementByXPath(aTab, aQuery) { + let doc = aTab.linkedBrowser.contentDocument; + let xptype = doc.defaultView.XPathResult.FIRST_ORDERED_NODE_TYPE; + return doc.evaluate(aQuery, doc, null, xptype, null).singleNodeValue; + } + + function setFormValue(aTab, aQuery, aValue) { + let node = getElementByXPath(aTab, aQuery); + if (typeof aValue == "string") { + node.value = aValue; + } else if (typeof aValue == "boolean") { + node.checked = aValue; + } else if (typeof aValue == "number") { + node.selectedIndex = aValue; + } else { + Array.prototype.forEach.call( + node.options, + (aOpt, aIx) => (aOpt.selected = aValue.indexOf(aIx) > -1) + ); + } + } + + function compareFormValue(aTab, aQuery, aValue) { + let node = getElementByXPath(aTab, aQuery); + if (!node) { + return false; + } + if (ChromeUtils.getClassName(node) === "HTMLInputElement") { + return ( + aValue == + (node.type == "checkbox" || node.type == "radio" + ? node.checked + : node.value) + ); + } + if (ChromeUtils.getClassName(node) === "HTMLTextAreaElement") { + return aValue == node.value; + } + if (!node.multiple) { + return aValue == node.selectedIndex; + } + return Array.prototype.every.call( + node.options, + (aOpt, aIx) => aValue.indexOf(aIx) > -1 == aOpt.selected + ); + } + + /** + * Test (B) : Session data restoration between windows + */ + + let rootDir = getRootDirectory(gTestPath); + const testURL = rootDir + "browser_248970_b_sample.html"; + const testURL2 = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_248970_b_sample.html"; + + whenNewWindowLoaded({ private: false }, function(aWin) { + windowsToClose.push(aWin); + + // get closed tab count + let count = ss.getClosedTabCount(aWin); + let max_tabs_undo = Services.prefs.getIntPref( + "browser.sessionstore.max_tabs_undo" + ); + ok( + 0 <= count && count <= max_tabs_undo, + "getClosedTabCount should return zero or at most max_tabs_undo" + ); + + // setup a state for tab (A) so we can check later that is restored + let value = "Value " + Math.random(); + let state = { entries: [{ url: testURL }], extData: { key: value } }; + + // public session, add new tab: (A) + let tab_A = BrowserTestUtils.addTab(aWin.gBrowser, testURL); + ss.setTabState(tab_A, JSON.stringify(state)); + promiseBrowserLoaded(tab_A.linkedBrowser).then(() => { + // make sure that the next closed tab will increase getClosedTabCount + Services.prefs.setIntPref( + "browser.sessionstore.max_tabs_undo", + max_tabs_undo + 1 + ); + + // populate tab_A with form data + for (let i in fieldList) { + setFormValue(tab_A, i, fieldList[i]); + } + + // public session, close tab: (A) + aWin.gBrowser.removeTab(tab_A); + + // verify that closedTabCount increased + ok( + ss.getClosedTabCount(aWin) > count, + "getClosedTabCount has increased after closing a tab" + ); + + // verify tab: (A), in undo list + let tab_A_restored = checkNoThrow(() => ss.undoCloseTab(aWin, 0)); + ok(tab_A_restored, "a tab is in undo list"); + promiseTabRestored(tab_A_restored).then(() => { + is( + testURL, + tab_A_restored.linkedBrowser.currentURI.spec, + "it's the same tab that we expect" + ); + aWin.gBrowser.removeTab(tab_A_restored); + + whenNewWindowLoaded({ private: true }, function(win) { + windowsToClose.push(win); + + // setup a state for tab (B) so we can check that its duplicated + // properly + let key1 = "key1"; + let value1 = "Value " + Math.random(); + let state1 = { + entries: [{ url: testURL2 }], + extData: { key1: value1 }, + }; + + let tab_B = BrowserTestUtils.addTab(win.gBrowser, testURL2); + promiseTabState(tab_B, state1).then(() => { + // populate tab: (B) with different form data + for (let item in fieldList) { + setFormValue(tab_B, item, fieldList[item]); + } + + // duplicate tab: (B) + let tab_C = win.gBrowser.duplicateTab(tab_B); + promiseTabRestored(tab_C).then(() => { + // verify the correctness of the duplicated tab + is( + ss.getCustomTabValue(tab_C, key1), + value1, + "tab successfully duplicated - correct state" + ); + + for (let item in fieldList) { + ok( + compareFormValue(tab_C, item, fieldList[item]), + 'The value for "' + item + '" was correctly duplicated' + ); + } + + // private browsing session, close tab: (C) and (B) + win.gBrowser.removeTab(tab_C); + win.gBrowser.removeTab(tab_B); + + finish(); + }); + }); + }); + }); + }); + }); +} diff --git a/browser/components/sessionstore/test/browser_248970_b_sample.html b/browser/components/sessionstore/test/browser_248970_b_sample.html new file mode 100644 index 0000000000..76c3ae1aa0 --- /dev/null +++ b/browser/components/sessionstore/test/browser_248970_b_sample.html @@ -0,0 +1,37 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"> +<meta charset="utf-8"> +<title>Test for bug 248970</title> + +<h3>Text Fields</h3> +<input type="text" name="input"> +<input type="text" name="spaced 1"> +<input> + +<h3>Checkboxes and Radio buttons</h3> +<input type="checkbox" name="check"> Check 1 +<input type="checkbox" name="uncheck" checked> Check 2 +<p> +<input type="radio" name="group" value="1"> Radio 1 +<input type="radio" name="group" value="some"> Radio 2 +<input type="radio" name="group" checked> Radio 3 + +<h3>Selects</h3> +<select name="any"> + <option value="1"> Select 1 + <option value="some"> Select 2 + <option>Select 3 +</select> +<select multiple="multiple"> + <option value=1> Multi-select 1 + <option value=2> Multi-select 2 + <option value=3> Multi-select 3 + <option value=4> Multi-select 4 +</select> + +<h3>Text Areas</h3> +<textarea name="testarea"></textarea> +<textarea name="sized one" rows="5" cols="25"></textarea> +<textarea></textarea> + +<h3>File Selector</h3> +<input type="file"> diff --git a/browser/components/sessionstore/test/browser_339445.js b/browser/components/sessionstore/test/browser_339445.js new file mode 100644 index 0000000000..58a0f8dbb5 --- /dev/null +++ b/browser/components/sessionstore/test/browser_339445.js @@ -0,0 +1,39 @@ +/* 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/. */ + +add_task(async function test() { + /** Test for Bug 339445 **/ + + let testURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_339445_sample.html"; + + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + await promiseBrowserLoaded(tab.linkedBrowser, true, testURL); + + await SpecialPowers.spawn(tab.linkedBrowser, [], function() { + let doc = content.document; + is( + doc.getElementById("storageTestItem").textContent, + "PENDING", + "sessionStorage value has been set" + ); + }); + + let tab2 = gBrowser.duplicateTab(tab); + await promiseTabRestored(tab2); + + await ContentTask.spawn(tab2.linkedBrowser, null, function() { + let doc2 = content.document; + is( + doc2.getElementById("storageTestItem").textContent, + "SUCCESS", + "sessionStorage value has been duplicated" + ); + }); + + // clean up + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_339445_sample.html b/browser/components/sessionstore/test/browser_339445_sample.html new file mode 100644 index 0000000000..ff5b4acd9f --- /dev/null +++ b/browser/components/sessionstore/test/browser_339445_sample.html @@ -0,0 +1,18 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"> +<meta charset="utf-8"> +<title>Test for bug 339445</title> + +storageTestItem = <span id="storageTestItem">FAIL</span> + +<!-- + storageTestItem's textContent will be one of the following: + * FAIL : sessionStorage wasn't available + * PENDING : the test value has been initialized on first load + * SUCCESS : the test value was correctly retrieved +--> + +<script type="application/javascript"> + document.getElementById("storageTestItem").textContent = + sessionStorage.storageTestItem || "PENDING"; + sessionStorage.storageTestItem = "SUCCESS"; +</script> diff --git a/browser/components/sessionstore/test/browser_345898.js b/browser/components/sessionstore/test/browser_345898.js new file mode 100644 index 0000000000..f9900a3d1b --- /dev/null +++ b/browser/components/sessionstore/test/browser_345898.js @@ -0,0 +1,69 @@ +/* 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/. */ + +function test() { + /** Test for Bug 345898 **/ + + // all of the following calls with illegal arguments should throw NS_ERROR_ILLEGAL_VALUE + Assert.throws( + () => ss.getWindowState({}), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for getWindowState throws" + ); + Assert.throws( + () => ss.setWindowState({}, "", false), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for setWindowState throws" + ); + Assert.throws( + () => ss.getTabState({}), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid tab for getTabState throws" + ); + Assert.throws( + () => ss.setTabState({}, "{}"), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid tab state for setTabState throws" + ); + Assert.throws( + () => ss.setTabState({}, JSON.stringify({ entries: [] })), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid tab for setTabState throws" + ); + Assert.throws( + () => ss.duplicateTab({}, {}), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid tab for duplicateTab throws" + ); + Assert.throws( + () => ss.duplicateTab({}, gBrowser.selectedTab), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for duplicateTab throws" + ); + Assert.throws( + () => ss.getClosedTabData({}), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for getClosedTabData throws" + ); + Assert.throws( + () => ss.undoCloseTab({}, 0), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for undoCloseTab throws" + ); + Assert.throws( + () => ss.undoCloseTab(window, -1), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid index for undoCloseTab throws" + ); + Assert.throws( + () => ss.getCustomWindowValue({}, ""), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for getCustomWindowValue throws" + ); + Assert.throws( + () => ss.setCustomWindowValue({}, "", ""), + /NS_ERROR_ILLEGAL_VALUE/, + "Invalid window for setCustomWindowValue throws" + ); +} diff --git a/browser/components/sessionstore/test/browser_350525.js b/browser/components/sessionstore/test/browser_350525.js new file mode 100644 index 0000000000..812e5f1310 --- /dev/null +++ b/browser/components/sessionstore/test/browser_350525.js @@ -0,0 +1,135 @@ +"use strict"; + +add_setup(async function() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]], + }); +}); + +add_task(async function() { + /** Test for Bug 350525 **/ + + function test(aLambda) { + try { + return aLambda() || true; + } catch (ex) {} + return false; + } + + /** + * setCustomWindowValue, et al. + */ + let key = "Unique name: " + Date.now(); + let value = "Unique value: " + Math.random(); + + // test adding + ok( + test(() => ss.setCustomWindowValue(window, key, value)), + "set a window value" + ); + + // test retrieving + is( + ss.getCustomWindowValue(window, key), + value, + "stored window value matches original" + ); + + // test deleting + ok( + test(() => ss.deleteCustomWindowValue(window, key)), + "delete the window value" + ); + + // value should not exist post-delete + is(ss.getCustomWindowValue(window, key), "", "window value was deleted"); + + // test deleting a non-existent value + ok( + test(() => ss.deleteCustomWindowValue(window, key)), + "delete non-existent window value" + ); + + /** + * setCustomTabValue, et al. + */ + key = "Unique name: " + Math.random(); + value = "Unique value: " + Date.now(); + let tab = BrowserTestUtils.addTab(gBrowser); + tab.linkedBrowser.stop(); + + // test adding + ok( + test(() => ss.setCustomTabValue(tab, key, value)), + "store a tab value" + ); + + // test retrieving + is(ss.getCustomTabValue(tab, key), value, "stored tab value match original"); + + // test deleting + ok( + test(() => ss.deleteCustomTabValue(tab, key)), + "delete the tab value" + ); + + // value should not exist post-delete + is(ss.getCustomTabValue(tab, key), "", "tab value was deleted"); + + // test deleting a non-existent value + ok( + test(() => ss.deleteCustomTabValue(tab, key)), + "delete non-existent tab value" + ); + + // clean up + await promiseRemoveTabAndSessionState(tab); + + /** + * getClosedTabCount, undoCloseTab + */ + + // get closed tab count + let count = ss.getClosedTabCount(window); + let max_tabs_undo = Services.prefs.getIntPref( + "browser.sessionstore.max_tabs_undo" + ); + ok( + 0 <= count && count <= max_tabs_undo, + "getClosedTabCount returns zero or at most max_tabs_undo" + ); + + // create a new tab + let testURL = "about:mozilla"; + tab = BrowserTestUtils.addTab(gBrowser, testURL); + await promiseBrowserLoaded(tab.linkedBrowser); + + // make sure that the next closed tab will increase getClosedTabCount + Services.prefs.setIntPref( + "browser.sessionstore.max_tabs_undo", + max_tabs_undo + 1 + ); + registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.sessionstore.max_tabs_undo") + ); + + // remove tab + await promiseRemoveTabAndSessionState(tab); + + // getClosedTabCount + let newcount = ss.getClosedTabCount(window); + ok( + newcount > count, + "after closing a tab, getClosedTabCount has been incremented" + ); + + // undoCloseTab + tab = test(() => ss.undoCloseTab(window, 0)); + ok(tab, "undoCloseTab doesn't throw"); + + await promiseTabRestored(tab); + is(tab.linkedBrowser.currentURI.spec, testURL, "correct tab was reopened"); + + // clean up + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_354894_perwindowpb.js b/browser/components/sessionstore/test/browser_354894_perwindowpb.js new file mode 100644 index 0000000000..27f822f8ef --- /dev/null +++ b/browser/components/sessionstore/test/browser_354894_perwindowpb.js @@ -0,0 +1,492 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Checks that restoring the last browser window in session is actually + * working. + * + * @see https://bugzilla.mozilla.org/show_bug.cgi?id=354894 + * @note It is implicitly tested that restoring the last window works when + * non-browser windows are around. The "Run Tests" window as well as the main + * browser window (wherein the test code gets executed) won't be considered + * browser windows. To achiveve this said main browser window has its windowtype + * attribute modified so that it's not considered a browser window any longer. + * This is crucial, because otherwise there would be two browser windows around, + * said main test window and the one opened by the tests, and hence the new + * logic wouldn't be executed at all. + * @note Mac only tests the new notifications, as restoring the last window is + * not enabled on that platform (platform shim; the application is kept running + * although there are no windows left) + * @note There is a difference when closing a browser window with + * BrowserTryToCloseWindow() as opposed to close(). The former will make + * nsSessionStore restore a window next time it gets a chance and will post + * notifications. The latter won't. + */ + +const { SessionStartup } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStartup.sys.mjs" +); +// The rejection "BrowserWindowTracker.getTopWindow(...) is null" is left +// unhandled in some cases. This bug should be fixed, but for the moment this +// file allows a class of rejections. +// +// NOTE: Allowing a whole class of rejections should be avoided. Normally you +// should use "expectUncaughtRejection" to flag individual failures. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/getTopWindow/); + +// Some urls that might be opened in tabs and/or popups +// Do not use about:blank: +// That one is reserved for special purposes in the tests +const TEST_URLS = ["about:mozilla", "about:buildconfig"]; + +// Number of -request notifications to except +// remember to adjust when adding new tests +const NOTIFICATIONS_EXPECTED = 6; + +// Window features of popup windows +const POPUP_FEATURES = "toolbar=no,resizable=no,status=no"; + +// Window features of browser windows +const CHROME_FEATURES = "chrome,all,dialog=no"; + +const IS_MAC = navigator.platform.match(/Mac/); + +/** + * Returns an Object with two properties: + * open (int): + * A count of how many non-closed navigator:browser windows there are. + * winstates (int): + * A count of how many windows there are in the SessionStore state. + */ +function getBrowserWindowsCount() { + let open = 0; + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (!win.closed) { + ++open; + } + } + + let winstates = JSON.parse(ss.getBrowserState()).windows.length; + + return { open, winstates }; +} + +add_setup(async function() { + // Make sure we've only got one browser window to start with + let { open, winstates } = getBrowserWindowsCount(); + is(open, 1, "Should only be one open window"); + is(winstates, 1, "Should only be one window state in SessionStore"); + + // This test takes some time to run, and it could timeout randomly. + // So we require a longer timeout. See bug 528219. + requestLongerTimeout(3); + + // Make the main test window not count as a browser window any longer + let oldWinType = document.documentElement.getAttribute("windowtype"); + document.documentElement.setAttribute("windowtype", "navigator:testrunner"); + + registerCleanupFunction(() => { + document.documentElement.setAttribute("windowtype", oldWinType); + }); +}); + +/** + * Sets up one of our tests by setting the right preferences, and + * then opening up a browser window preloaded with some tabs. + * + * @param options (Object) + * An object that can contain the following properties: + * + * private: + * Whether or not the opened window should be private. + * + * denyFirst: + * Whether or not the first window that attempts to close + * via closeWindowForRestoration should be denied. + * + * @param testFunction (Function*) + * A generator function that yields Promises to be run + * once the test has been set up. + * + * @returns Promise + * Resolves once the test has been cleaned up. + */ +let setupTest = async function(options, testFunction) { + await pushPrefs( + ["browser.startup.page", 3], + ["browser.tabs.warnOnClose", false] + ); + // SessionStartup caches pref values, but as this test tries to simulate a + // startup scenario, we'll reset them here. + SessionStartup.resetForTest(); + + // Observe these, and also use to count the number of hits + let observing = { + "browser-lastwindow-close-requested": 0, + "browser-lastwindow-close-granted": 0, + }; + + /** + * Helper: Will observe and handle the notifications for us + */ + let hitCount = 0; + function observer(aCancel, aTopic, aData) { + // count so that we later may compare + observing[aTopic]++; + + // handle some tests + if (options.denyFirst && ++hitCount == 1) { + aCancel.QueryInterface(Ci.nsISupportsPRBool).data = true; + } + } + + for (let o in observing) { + Services.obs.addObserver(observer, o); + } + + let newWin = await promiseNewWindowLoaded({ + private: options.private || false, + }); + + await injectTestTabs(newWin); + + await testFunction(newWin, observing); + + let count = getBrowserWindowsCount(); + is(count.open, 0, "Got right number of open windows"); + is(count.winstates, 1, "Got right number of stored window states"); + + for (let o in observing) { + Services.obs.removeObserver(observer, o); + } + + await popPrefs(); + // Act like nothing ever happened. + SessionStartup.resetForTest(); +}; + +/** + * Loads a TEST_URLS into a browser window. + * + * @param win (Window) + * The browser window to load the tabs in + */ +function injectTestTabs(win) { + let promises = TEST_URLS.map(url => + BrowserTestUtils.addTab(win.gBrowser, url) + ).map(tab => BrowserTestUtils.browserLoaded(tab.linkedBrowser)); + return Promise.all(promises); +} + +/** + * Attempts to close a window via BrowserTryToCloseWindow so that + * we get the browser-lastwindow-close-requested and + * browser-lastwindow-close-granted observer notifications. + * + * @param win (Window) + * The window to try to close + * @returns Promise + * Resolves to true if the window closed, or false if the window + * was denied the ability to close. + */ +function closeWindowForRestoration(win) { + return new Promise(resolve => { + let closePromise = BrowserTestUtils.windowClosed(win); + win.BrowserTryToCloseWindow(); + if (!win.closed) { + resolve(false); + return; + } + + closePromise.then(() => { + resolve(true); + }); + }); +} + +/** + * Normal in-session restore + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open a new browser window + * 2. Add some tabs + * 3. Close that window + * 4. Opening another window + * 5. Checks that state is restored + */ +add_task(async function test_open_close_normal() { + if (IS_MAC) { + return; + } + + await setupTest({ denyFirst: true }, async function(newWin, obs) { + let closed = await closeWindowForRestoration(newWin); + ok(!closed, "First close request should have been denied"); + + closed = await closeWindowForRestoration(newWin); + ok(closed, "Second close request should be accepted"); + + newWin = await promiseNewWindowLoaded(); + is( + newWin.gBrowser.browsers.length, + TEST_URLS.length + 2, + "Restored window in-session with otherpopup windows around" + ); + + // Note that this will not result in the the browser-lastwindow-close + // notifications firing for this other newWin. + await BrowserTestUtils.closeWindow(newWin); + + // setupTest gave us a window which was denied for closing once, and then + // closed. + is( + obs["browser-lastwindow-close-requested"], + 2, + "Got expected browser-lastwindow-close-requested notifications" + ); + is( + obs["browser-lastwindow-close-granted"], + 1, + "Got expected browser-lastwindow-close-granted notifications" + ); + }); +}); + +/** + * PrivateBrowsing in-session restore + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open a new browser window A + * 2. Add some tabs + * 3. Close the window A as the last window + * 4. Open a private browsing window B + * 5. Make sure that B didn't restore the tabs from A + * 6. Close private browsing window B + * 7. Open a new window C + * 8. Make sure that new window C has restored tabs from A + */ +add_task(async function test_open_close_private_browsing() { + if (IS_MAC) { + return; + } + + await setupTest({}, async function(newWin, obs) { + let closed = await closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + + newWin = await promiseNewWindowLoaded({ private: true }); + is( + newWin.gBrowser.browsers.length, + 1, + "Did not restore in private browsing mode" + ); + + closed = await closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + + newWin = await promiseNewWindowLoaded(); + is( + newWin.gBrowser.browsers.length, + TEST_URLS.length + 2, + "Restored tabs in a new non-private window" + ); + + // Note that this will not result in the the browser-lastwindow-close + // notifications firing for this other newWin. + await BrowserTestUtils.closeWindow(newWin); + + // We closed two windows with closeWindowForRestoration, and both + // should have been successful. + is( + obs["browser-lastwindow-close-requested"], + 2, + "Got expected browser-lastwindow-close-requested notifications" + ); + is( + obs["browser-lastwindow-close-granted"], + 2, + "Got expected browser-lastwindow-close-granted notifications" + ); + }); +}); + +/** + * Open some popup window to check it isn't restored. Instead nothing at all + * should be restored + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open a popup + * 2. Add another tab to the popup (so that it gets stored) and close it again + * 3. Open a window + * 4. Check that nothing at all is restored + * 5. Open two browser windows and close them again + * 6. undoCloseWindow() one + * 7. Open another browser window + * 8. Check that nothing at all is restored + */ +add_task(async function test_open_close_only_popup() { + if (IS_MAC) { + return; + } + + await setupTest({}, async function(newWin, obs) { + // We actually don't care about the initial window in this test. + await BrowserTestUtils.closeWindow(newWin); + + // This will cause nsSessionStore to restore a window the next time it + // gets a chance. + let popupPromise = BrowserTestUtils.waitForNewWindow(); + openDialog(location, "popup", POPUP_FEATURES, TEST_URLS[1]); + let popup = await popupPromise; + + is( + popup.gBrowser.browsers.length, + 1, + "Did not restore the popup window (1)" + ); + + let closed = await closeWindowForRestoration(popup); + ok(closed, "Should be able to close the window"); + + popupPromise = BrowserTestUtils.waitForNewWindow(); + openDialog(location, "popup", POPUP_FEATURES, TEST_URLS[1]); + popup = await popupPromise; + + BrowserTestUtils.addTab(popup.gBrowser, TEST_URLS[0]); + is( + popup.gBrowser.browsers.length, + 2, + "Did not restore to the popup window (2)" + ); + + await BrowserTestUtils.closeWindow(popup); + + newWin = await promiseNewWindowLoaded(); + isnot( + newWin.gBrowser.browsers.length, + 2, + "Did not restore the popup window" + ); + is( + TEST_URLS.indexOf(newWin.gBrowser.browsers[0].currentURI.spec), + -1, + "Did not restore the popup window (2)" + ); + await BrowserTestUtils.closeWindow(newWin); + + // We closed one popup window with closeWindowForRestoration, and popup + // windows should never fire the browser-lastwindow notifications. + is( + obs["browser-lastwindow-close-requested"], + 0, + "Got expected browser-lastwindow-close-requested notifications" + ); + is( + obs["browser-lastwindow-close-granted"], + 0, + "Got expected browser-lastwindow-close-granted notifications" + ); + }); +}); + +/** + * Open some windows and do undoCloseWindow. This should prevent any + * restoring later in the test + * + * @note: Non-Mac only + * + * Should do the following: + * 1. Open two browser windows and close them again + * 2. undoCloseWindow() one + * 3. Open another browser window + * 4. Make sure nothing at all is restored + */ +add_task(async function test_open_close_restore_from_popup() { + if (IS_MAC) { + return; + } + + await setupTest({}, async function(newWin, obs) { + let newWin2 = await promiseNewWindowLoaded(); + await injectTestTabs(newWin2); + + let closed = await closeWindowForRestoration(newWin); + ok(closed, "Should be able to close the window"); + closed = await closeWindowForRestoration(newWin2); + ok(closed, "Should be able to close the window"); + + let counts = getBrowserWindowsCount(); + is(counts.open, 0, "Got right number of open windows"); + is(counts.winstates, 1, "Got right number of window states"); + + newWin = undoCloseWindow(0); + await BrowserTestUtils.waitForEvent(newWin, "load"); + + // Make sure we wait until this window is restored. + await BrowserTestUtils.waitForEvent( + newWin.gBrowser.tabContainer, + "SSTabRestored" + ); + + newWin2 = await promiseNewWindowLoaded(); + + is( + TEST_URLS.indexOf(newWin2.gBrowser.browsers[0].currentURI.spec), + -1, + "Did not restore, as undoCloseWindow() was last called (2)" + ); + + counts = getBrowserWindowsCount(); + is(counts.open, 2, "Got right number of open windows"); + is(counts.winstates, 3, "Got right number of window states"); + + await BrowserTestUtils.closeWindow(newWin); + await BrowserTestUtils.closeWindow(newWin2); + + counts = getBrowserWindowsCount(); + is(counts.open, 0, "Got right number of open windows"); + is(counts.winstates, 1, "Got right number of window states"); + }); +}); + +/** + * Test if closing can be denied on Mac. + * @note: Mac only + */ +add_task(async function test_mac_notifications() { + if (!IS_MAC) { + return; + } + + await setupTest({ denyFirst: true }, async function(newWin, obs) { + let closed = await closeWindowForRestoration(newWin); + ok(!closed, "First close attempt should be denied"); + closed = await closeWindowForRestoration(newWin); + ok(closed, "Second close attempt should be granted"); + + // We tried closing once, and got denied. Then we tried again and + // succeeded. That means 2 close requests, and 1 close granted. + is( + obs["browser-lastwindow-close-requested"], + 2, + "Got expected browser-lastwindow-close-requested notifications" + ); + is( + obs["browser-lastwindow-close-granted"], + 1, + "Got expected browser-lastwindow-close-granted notifications" + ); + }); +}); diff --git a/browser/components/sessionstore/test/browser_367052.js b/browser/components/sessionstore/test/browser_367052.js new file mode 100644 index 0000000000..391eb77680 --- /dev/null +++ b/browser/components/sessionstore/test/browser_367052.js @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function() { + // make sure that the next closed tab will increase getClosedTabCount + let max_tabs_undo = Services.prefs.getIntPref( + "browser.sessionstore.max_tabs_undo" + ); + Services.prefs.setIntPref( + "browser.sessionstore.max_tabs_undo", + max_tabs_undo + 1 + ); + registerCleanupFunction(() => + Services.prefs.clearUserPref("browser.sessionstore.max_tabs_undo") + ); + + // Empty the list of closed tabs. + while (ss.getClosedTabCount(window)) { + ss.forgetClosedTab(window, 0); + } + + // restore a blank tab + let tab = BrowserTestUtils.addTab(gBrowser, "about:mozilla"); + await promiseBrowserLoaded(tab.linkedBrowser); + + let count = await promiseSHistoryCount(tab.linkedBrowser); + ok(count >= 1, "the new tab does have at least one history entry"); + + await promiseTabState(tab, { entries: [] }); + + // We may have a different sessionHistory object if the tab + // switched from non-remote to remote. + count = await promiseSHistoryCount(tab.linkedBrowser); + is(count, 0, "the tab was restored without any history whatsoever"); + + await promiseRemoveTabAndSessionState(tab); + is( + ss.getClosedTabCount(window), + 0, + "The closed blank tab wasn't added to Recently Closed Tabs" + ); +}); + +function promiseSHistoryCount(browser) { + return SpecialPowers.spawn(browser, [], async function() { + return docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory.count; + }); +} diff --git a/browser/components/sessionstore/test/browser_393716.js b/browser/components/sessionstore/test/browser_393716.js new file mode 100644 index 0000000000..6a98435724 --- /dev/null +++ b/browser/components/sessionstore/test/browser_393716.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = "about:config"; + +add_setup(async function() { + // Make sure that the field of which we restore the state is visible on load. + await SpecialPowers.pushPrefEnv({ + set: [["browser.aboutConfig.showWarning", false]], + }); +}); + +/** + * Bug 393716 - Basic tests for getTabState(), setTabState(), and duplicateTab(). + */ +add_task(async function test_set_tabstate() { + let key = "Unique key: " + Date.now(); + let value = "Unique value: " + Math.random(); + + // create a new tab + let tab = BrowserTestUtils.addTab(gBrowser, URL); + ss.setCustomTabValue(tab, key, value); + await promiseBrowserLoaded(tab.linkedBrowser); + + // get the tab's state + await TabStateFlusher.flush(tab.linkedBrowser); + let state = ss.getTabState(tab); + ok(state, "get the tab's state"); + + // verify the tab state's integrity + state = JSON.parse(state); + ok( + state instanceof Object && + state.entries instanceof Array && + !!state.entries.length, + "state object seems valid" + ); + ok( + state.entries.length == 1 && state.entries[0].url == URL, + "Got the expected state object (test URL)" + ); + ok( + state.extData && state.extData[key] == value, + "Got the expected state object (test manually set tab value)" + ); + + // clean up + gBrowser.removeTab(tab); +}); + +add_task(async function test_set_tabstate_and_duplicate() { + let key2 = "key2"; + let value2 = "Value " + Math.random(); + let value3 = "Another value: " + Date.now(); + let state = { + entries: [{ url: URL, triggeringPrincipal_base64 }], + extData: { key2: value2 }, + }; + + // create a new tab + let tab = BrowserTestUtils.addTab(gBrowser); + // set the tab's state + ss.setTabState(tab, JSON.stringify(state)); + await promiseBrowserLoaded(tab.linkedBrowser); + + // verify the correctness of the restored tab + ok( + ss.getCustomTabValue(tab, key2) == value2 && + tab.linkedBrowser.currentURI.spec == URL, + "the tab's state was correctly restored" + ); + + // add text data + await setPropertyOfFormField( + tab.linkedBrowser, + "#about-config-search", + "value", + value3 + ); + + // duplicate the tab + let tab2 = ss.duplicateTab(window, tab); + await promiseTabRestored(tab2); + + // verify the correctness of the duplicated tab + ok( + ss.getCustomTabValue(tab2, key2) == value2 && + tab2.linkedBrowser.currentURI.spec == URL, + "correctly duplicated the tab's state" + ); + let textbox = await getPropertyOfFormField( + tab2.linkedBrowser, + "#about-config-search", + "value" + ); + is(textbox, value3, "also duplicated text data"); + + // clean up + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_394759_basic.js b/browser/components/sessionstore/test/browser_394759_basic.js new file mode 100644 index 0000000000..442f04cd0c --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_basic.js @@ -0,0 +1,128 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TEST_URL = + "data:text/html;charset=utf-8,<input%20id=txt>" + + "<input%20type=checkbox%20id=chk>"; + +const { SessionStore } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" +); + +/** + * This test ensures that closing a window is a reversible action. We will + * close the the window, restore it and check that all data has been restored. + * This includes window-specific data as well as form data for tabs. + */ +function test() { + waitForExplicitFinish(); + + let uniqueKey = "bug 394759"; + let uniqueValue = "unik" + Date.now(); + let uniqueText = "pi != " + Math.random(); + + // Clear the list of closed windows. + forgetClosedWindows(); + + provideWindow(function onTestURLLoaded(newWin) { + BrowserTestUtils.addTab(newWin.gBrowser).linkedBrowser.stop(); + + // Mark the window with some unique data to be restored later on. + ss.setCustomWindowValue(newWin, uniqueKey, uniqueValue); + let [txt] = newWin.content.document.querySelectorAll("#txt"); + txt.value = uniqueText; + + let browser = newWin.gBrowser.selectedBrowser; + + setPropertyOfFormField(browser, "#chk", "checked", true).then(() => { + BrowserTestUtils.closeWindow(newWin).then(() => { + is( + ss.getClosedWindowCount(), + 1, + "The closed window was added to Recently Closed Windows" + ); + + let data = SessionStore.getClosedWindowData(); + + // Verify that non JSON serialized data is the same as JSON serialized data. + is( + JSON.stringify(data), + ss.getClosedWindowData(), + "Non-serialized data is the same as serialized data" + ); + + ok( + data[0].title == TEST_URL && + JSON.stringify(data[0]).indexOf(uniqueText) > -1, + "The closed window data was stored correctly" + ); + + // Reopen the closed window and ensure its integrity. + let newWin2 = ss.undoCloseWindow(0); + + ok( + newWin2.isChromeWindow, + "undoCloseWindow actually returned a window" + ); + is( + ss.getClosedWindowCount(), + 0, + "The reopened window was removed from Recently Closed Windows" + ); + + // SSTabRestored will fire more than once, so we need to make sure we count them. + let restoredTabs = 0; + let expectedTabs = data[0].tabs.length; + newWin2.addEventListener( + "SSTabRestored", + function sstabrestoredListener(aEvent) { + ++restoredTabs; + info("Restored tab " + restoredTabs + "/" + expectedTabs); + if (restoredTabs < expectedTabs) { + return; + } + + is(restoredTabs, expectedTabs, "Correct number of tabs restored"); + newWin2.removeEventListener( + "SSTabRestored", + sstabrestoredListener, + true + ); + + is( + newWin2.gBrowser.tabs.length, + 2, + "The window correctly restored 2 tabs" + ); + is( + newWin2.gBrowser.currentURI.spec, + TEST_URL, + "The window correctly restored the URL" + ); + + let chk; + [txt, chk] = newWin2.content.document.querySelectorAll( + "#txt, #chk" + ); + ok( + txt.value == uniqueText && chk.checked, + "The window correctly restored the form" + ); + is( + ss.getCustomWindowValue(newWin2, uniqueKey), + uniqueValue, + "The window correctly restored the data associated with it" + ); + + // Clean up. + BrowserTestUtils.closeWindow(newWin2).then(finish); + }, + true + ); + }); + }); + }, TEST_URL); +} diff --git a/browser/components/sessionstore/test/browser_394759_behavior.js b/browser/components/sessionstore/test/browser_394759_behavior.js new file mode 100644 index 0000000000..5a42f5c3d8 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_behavior.js @@ -0,0 +1,91 @@ +/** + * Test helper function that opens a series of windows, closes them + * and then checks the closed window data from SessionStore against + * expected results. + * + * @param windowsToOpen (Array) + * An array of Objects, where each object must define a single + * property "isPopup" for whether or not the opened window should + * be a popup. + * @param expectedResults (Array) + * An Object with two properies: mac and other, where each points + * at yet another Object, with the following properties: + * + * popup (int): + * The number of popup windows we expect to be in the closed window + * data. + * normal (int): + * The number of normal windows we expect to be in the closed window + * data. + * @returns Promise + */ +function testWindows(windowsToOpen, expectedResults) { + return (async function() { + let num = 0; + for (let winData of windowsToOpen) { + let features = "chrome,dialog=no," + (winData.isPopup ? "all=no" : "all"); + let url = "http://example.com/?window=" + num; + num = num + 1; + + let openWindowPromise = BrowserTestUtils.waitForNewWindow({ url }); + openDialog(AppConstants.BROWSER_CHROME_URL, "", features, url); + let win = await openWindowPromise; + await BrowserTestUtils.closeWindow(win); + } + + let closedWindowData = ss.getClosedWindowData(); + let numPopups = closedWindowData.filter(function(el, i, arr) { + return el.isPopup; + }).length; + let numNormal = ss.getClosedWindowCount() - numPopups; + // #ifdef doesn't work in browser-chrome tests, so do a simple regex on platform + let oResults = navigator.platform.match(/Mac/) + ? expectedResults.mac + : expectedResults.other; + is( + numPopups, + oResults.popup, + "There were " + oResults.popup + " popup windows to reopen" + ); + is( + numNormal, + oResults.normal, + "There were " + oResults.normal + " normal windows to repoen" + ); + })(); +} + +add_task(async function test_closed_window_states() { + // This test takes quite some time, and timeouts frequently, so we require + // more time to run. + // See Bug 518970. + requestLongerTimeout(2); + + let windowsToOpen = [ + { isPopup: false }, + { isPopup: false }, + { isPopup: true }, + { isPopup: true }, + { isPopup: true }, + ]; + let expectedResults = { + mac: { popup: 3, normal: 0 }, + other: { popup: 3, normal: 1 }, + }; + + await testWindows(windowsToOpen, expectedResults); + + let windowsToOpen2 = [ + { isPopup: false }, + { isPopup: false }, + { isPopup: false }, + { isPopup: false }, + { isPopup: false }, + ]; + let expectedResults2 = { + mac: { popup: 0, normal: 3 }, + other: { popup: 0, normal: 3 }, + }; + + await testWindows(windowsToOpen2, expectedResults2); +}); diff --git a/browser/components/sessionstore/test/browser_394759_perwindowpb.js b/browser/components/sessionstore/test/browser_394759_perwindowpb.js new file mode 100644 index 0000000000..ef4125e231 --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_perwindowpb.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TESTS = [ + { url: "about:config", key: "bug 394759 Non-PB", value: "uniq" + r() }, + { url: "about:mozilla", key: "bug 394759 PB", value: "uniq" + r() }, +]; + +function promiseTestOpenCloseWindow(aIsPrivate, aTest) { + return (async function() { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: aIsPrivate, + }); + BrowserTestUtils.loadURI(win.gBrowser.selectedBrowser, aTest.url); + await promiseBrowserLoaded(win.gBrowser.selectedBrowser, true, aTest.url); + // Mark the window with some unique data to be restored later on. + ss.setCustomWindowValue(win, aTest.key, aTest.value); + await TabStateFlusher.flushWindow(win); + // Close. + await BrowserTestUtils.closeWindow(win); + })(); +} + +function promiseTestOnWindow(aIsPrivate, aValue) { + return (async function() { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: aIsPrivate, + }); + await TabStateFlusher.flushWindow(win); + let data = ss.getClosedWindowData()[0]; + is( + ss.getClosedWindowCount(), + 1, + "Check that the closed window count hasn't changed" + ); + ok( + JSON.stringify(data).indexOf(aValue) > -1, + "Check the closed window data was stored correctly" + ); + registerCleanupFunction(() => BrowserTestUtils.closeWindow(win)); + })(); +} + +add_setup(async function() { + forgetClosedWindows(); + while (ss.getClosedTabCount(window) > 0) { + ss.forgetClosedTab(window, 0); + } +}); + +add_task(async function main() { + await promiseTestOpenCloseWindow(false, TESTS[0]); + await promiseTestOpenCloseWindow(true, TESTS[1]); + await promiseTestOnWindow(false, TESTS[0].value); + await promiseTestOnWindow(true, TESTS[0].value); +}); diff --git a/browser/components/sessionstore/test/browser_394759_purge.js b/browser/components/sessionstore/test/browser_394759_purge.js new file mode 100644 index 0000000000..13b9a8558d --- /dev/null +++ b/browser/components/sessionstore/test/browser_394759_purge.js @@ -0,0 +1,247 @@ +/* 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/. */ + +let { ForgetAboutSite } = ChromeUtils.import( + "resource://gre/modules/ForgetAboutSite.jsm" +); + +function promiseClearHistory() { + return new Promise(resolve => { + let observer = { + observe(aSubject, aTopic, aData) { + Services.obs.removeObserver( + this, + "browser:purge-session-history-for-domain" + ); + resolve(); + }, + }; + Services.obs.addObserver( + observer, + "browser:purge-session-history-for-domain" + ); + }); +} + +add_task(async function() { + // utility functions + function countClosedTabsByTitle(aClosedTabList, aTitle) { + return aClosedTabList.filter(aData => aData.title == aTitle).length; + } + + function countOpenTabsByTitle(aOpenTabList, aTitle) { + return aOpenTabList.filter(aData => + aData.entries.some(aEntry => aEntry.title == aTitle) + ).length; + } + + // backup old state + let oldState = ss.getBrowserState(); + let oldState_wins = JSON.parse(oldState).windows.length; + if (oldState_wins != 1) { + ok( + false, + "oldState in test_purge has " + oldState_wins + " windows instead of 1" + ); + } + + // create a new state for testing + const REMEMBER = Date.now(), + FORGET = Math.random(); + let testState = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.com/", triggeringPrincipal_base64 }, + ], + }, + ], + selected: 1, + }, + ], + _closedWindows: [ + // _closedWindows[0] + { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: FORGET, + }, + ], + }, + ], + selected: 2, + title: "mozilla.org", + _closedTabs: [], + }, + // _closedWindows[1] + { + tabs: [ + { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: FORGET, + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: FORGET, + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + ], + selected: 5, + _closedTabs: [], + }, + // _closedWindows[2] + { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + ], + selected: 1, + _closedTabs: [ + { + state: { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: FORGET, + }, + { + url: "http://mozilla.org/again", + triggeringPrincipal_base64, + title: "doesn't matter", + }, + ], + }, + pos: 1, + title: FORGET, + }, + { + state: { + entries: [ + { + url: "http://example.com", + triggeringPrincipal_base64, + title: REMEMBER, + }, + ], + }, + title: REMEMBER, + }, + ], + }, + ], + }; + + // set browser to test state + ss.setBrowserState(JSON.stringify(testState)); + + // purge domain & check that we purged correctly for closed windows + let clearHistoryPromise = promiseClearHistory(); + await ForgetAboutSite.removeDataFromDomain("mozilla.org"); + await clearHistoryPromise; + + let closedWindowData = ss.getClosedWindowData(); + + // First set of tests for _closedWindows[0] - tests basics + let win = closedWindowData[0]; + is(win.tabs.length, 1, "1 tab was removed"); + is(countOpenTabsByTitle(win.tabs, FORGET), 0, "The correct tab was removed"); + is( + countOpenTabsByTitle(win.tabs, REMEMBER), + 1, + "The correct tab was remembered" + ); + is(win.selected, 1, "Selected tab has changed"); + is(win.title, REMEMBER, "The window title was correctly updated"); + + // Test more complicated case + win = closedWindowData[1]; + is(win.tabs.length, 3, "2 tabs were removed"); + is( + countOpenTabsByTitle(win.tabs, FORGET), + 0, + "The correct tabs were removed" + ); + is( + countOpenTabsByTitle(win.tabs, REMEMBER), + 3, + "The correct tabs were remembered" + ); + is(win.selected, 3, "Selected tab has changed"); + is(win.title, REMEMBER, "The window title was correctly updated"); + + // Tests handling of _closedTabs + win = closedWindowData[2]; + is( + countClosedTabsByTitle(win._closedTabs, REMEMBER), + 1, + "The correct number of tabs were removed, and the correct ones" + ); + is( + countClosedTabsByTitle(win._closedTabs, FORGET), + 0, + "All tabs to be forgotten were indeed removed" + ); + + // restore pre-test state + ss.setBrowserState(oldState); +}); diff --git a/browser/components/sessionstore/test/browser_423132.js b/browser/components/sessionstore/test/browser_423132.js new file mode 100644 index 0000000000..a23904700a --- /dev/null +++ b/browser/components/sessionstore/test/browser_423132.js @@ -0,0 +1,52 @@ +"use strict"; + +/** + * Tests that cookies are stored and restored correctly + * by sessionstore (bug 423132). + */ +add_task(async function() { + const testURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_423132_sample.html"; + + Services.cookies.removeAll(); + // make sure that sessionstore.js can be forced to be created by setting + // the interval pref to 0 + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.interval", 0]], + }); + + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + + // get the sessionstore state for the window + let state = ss.getBrowserState(); + + // verify our cookie got set during pageload + let i = 0; + for (var cookie of Services.cookies.cookies) { + i++; + } + Assert.equal(i, 1, "expected one cookie"); + + // remove the cookie + Services.cookies.removeAll(); + + // restore the window state + await setBrowserState(state); + + // at this point, the cookie should be restored... + for (var cookie2 of Services.cookies.cookies) { + if (cookie.name == cookie2.name) { + break; + } + } + is(cookie.name, cookie2.name, "cookie name successfully restored"); + is(cookie.value, cookie2.value, "cookie value successfully restored"); + is(cookie.path, cookie2.path, "cookie path successfully restored"); + + // clean up + Services.cookies.removeAll(); + BrowserTestUtils.removeTab(gBrowser.tabs[1]); +}); diff --git a/browser/components/sessionstore/test/browser_423132_sample.html b/browser/components/sessionstore/test/browser_423132_sample.html new file mode 100644 index 0000000000..6ff7e7aa3e --- /dev/null +++ b/browser/components/sessionstore/test/browser_423132_sample.html @@ -0,0 +1,14 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <script type="text/javascript"> + // generate an enormous random number... + var r = Math.floor(Math.random() * Math.pow(2, 62)).toString(); + + // ... and use it to set a randomly named cookie + document.cookie = r + "=value; path=/ohai"; + </script> +<body> +</body> +</html> diff --git a/browser/components/sessionstore/test/browser_447951.js b/browser/components/sessionstore/test/browser_447951.js new file mode 100644 index 0000000000..84eaca8b33 --- /dev/null +++ b/browser/components/sessionstore/test/browser_447951.js @@ -0,0 +1,84 @@ +/* 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/. */ + +function test() { + /** Test for Bug 447951 **/ + + waitForExplicitFinish(); + const baseURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_447951_sample.html#"; + + // Make sure the functionality added in bug 943339 doesn't affect the results + Services.prefs.setIntPref("browser.sessionstore.max_serialize_back", -1); + Services.prefs.setIntPref("browser.sessionstore.max_serialize_forward", -1); + registerCleanupFunction(function() { + Services.prefs.clearUserPref("browser.sessionstore.max_serialize_back"); + Services.prefs.clearUserPref("browser.sessionstore.max_serialize_forward"); + }); + + let tab = BrowserTestUtils.addTab(gBrowser); + promiseBrowserLoaded(tab.linkedBrowser).then(() => { + let tabState = { entries: [] }; + let max_entries = Services.prefs.getIntPref( + "browser.sessionhistory.max_entries" + ); + for (let i = 0; i < max_entries; i++) { + tabState.entries.push({ url: baseURL + i, triggeringPrincipal_base64 }); + } + + promiseTabState(tab, tabState) + .then(() => { + return TabStateFlusher.flush(tab.linkedBrowser); + }) + .then(() => { + tabState = JSON.parse(ss.getTabState(tab)); + is( + tabState.entries.length, + max_entries, + "session history filled to the limit" + ); + is(tabState.entries[0].url, baseURL + 0, "... but not more"); + + // visit yet another anchor (appending it to session history) + SpecialPowers.spawn(tab.linkedBrowser, [], function() { + content.window.document.querySelector("a").click(); + }).then(flushAndCheck); + + function flushAndCheck() { + TabStateFlusher.flush(tab.linkedBrowser).then(check); + } + + function check() { + tabState = JSON.parse(ss.getTabState(tab)); + if (tab.linkedBrowser.currentURI.spec != baseURL + "end") { + // It may take a few passes through the event loop before we + // get the right URL. + executeSoon(flushAndCheck); + return; + } + + is( + tab.linkedBrowser.currentURI.spec, + baseURL + "end", + "the new anchor was loaded" + ); + is( + tabState.entries[tabState.entries.length - 1].url, + baseURL + "end", + "... and ignored" + ); + is( + tabState.entries[0].url, + baseURL + 1, + "... and the first item was removed" + ); + + // clean up + gBrowser.removeTab(tab); + finish(); + } + }); + }); +} diff --git a/browser/components/sessionstore/test/browser_447951_sample.html b/browser/components/sessionstore/test/browser_447951_sample.html new file mode 100644 index 0000000000..00282f25ef --- /dev/null +++ b/browser/components/sessionstore/test/browser_447951_sample.html @@ -0,0 +1,5 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Testcase for bug 447951</title> + +<a href="#end">click me</a> diff --git a/browser/components/sessionstore/test/browser_454908.js b/browser/components/sessionstore/test/browser_454908.js new file mode 100644 index 0000000000..415930c32d --- /dev/null +++ b/browser/components/sessionstore/test/browser_454908.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +if (gFissionBrowser) { + addCoopTask( + "browser_454908_sample.html", + test_dont_save_passwords, + HTTPSROOT + ); +} +addNonCoopTask("browser_454908_sample.html", test_dont_save_passwords, ROOT); +addNonCoopTask( + "browser_454908_sample.html", + test_dont_save_passwords, + HTTPROOT +); +addNonCoopTask( + "browser_454908_sample.html", + test_dont_save_passwords, + HTTPSROOT +); + +const PASS = "pwd-" + Math.random(); + +/** + * Bug 454908 - Don't save/restore values of password fields. + */ +async function test_dont_save_passwords(aURL) { + // Make sure we do save form data. + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); + + // Add a tab with a password field. + let tab = BrowserTestUtils.addTab(gBrowser, aURL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Fill in some values. + let usernameValue = "User " + Math.random(); + await setPropertyOfFormField(browser, "#username", "value", usernameValue); + await setPropertyOfFormField(browser, "#passwd", "value", PASS); + + // Close and restore the tab. + await promiseRemoveTabAndSessionState(tab); + tab = ss.undoCloseTab(window, 0); + browser = tab.linkedBrowser; + await promiseTabRestored(tab); + + // Check that password fields aren't saved/restored. + let username = await getPropertyOfFormField(browser, "#username", "value"); + is(username, usernameValue, "username was saved/restored"); + let passwd = await getPropertyOfFormField(browser, "#passwd", "value"); + is(passwd, "", "password wasn't saved/restored"); + + // Write to disk and read our file. + await forceSaveState(); + await promiseForEachSessionRestoreFile((state, key) => + // Ensure that we have not saved our password. + ok(!state.includes(PASS), "password has not been written to file " + key) + ); + + // Cleanup. + gBrowser.removeTab(tab); +} diff --git a/browser/components/sessionstore/test/browser_454908_sample.html b/browser/components/sessionstore/test/browser_454908_sample.html new file mode 100644 index 0000000000..02f40bf20b --- /dev/null +++ b/browser/components/sessionstore/test/browser_454908_sample.html @@ -0,0 +1,8 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"> +<title>Test for bug 454908</title> + +<h3>Dummy Login</h3> +<form> +<p>Username: <input type="text" id="username"> +<p>Password: <input type="password" id="passwd"> +</form> diff --git a/browser/components/sessionstore/test/browser_456342.js b/browser/components/sessionstore/test/browser_456342.js new file mode 100644 index 0000000000..09e36b5170 --- /dev/null +++ b/browser/components/sessionstore/test/browser_456342.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +addCoopTask( + "browser_456342_sample.xhtml", + test_restore_nonstandard_input_values, + HTTPSROOT +); +addNonCoopTask( + "browser_456342_sample.xhtml", + test_restore_nonstandard_input_values, + ROOT +); +addNonCoopTask( + "browser_456342_sample.xhtml", + test_restore_nonstandard_input_values, + HTTPROOT +); +addNonCoopTask( + "browser_456342_sample.xhtml", + test_restore_nonstandard_input_values, + HTTPSROOT +); + +const EXPECTED_IDS = new Set(["searchTerm"]); + +const EXPECTED_XPATHS = new Set([ + "/xhtml:html/xhtml:body/xhtml:form/xhtml:p[2]/xhtml:input", + "/xhtml:html/xhtml:body/xhtml:form/xhtml:p[3]/xhtml:input[@name='fill-in']", + "/xhtml:html/xhtml:body/xhtml:form/xhtml:p[4]/xhtml:input[@name='mistyped']", + "/xhtml:html/xhtml:body/xhtml:form/xhtml:p[5]/xhtml:textarea[@name='textarea_pass']", +]); + +/** + * Bug 456342 - Restore values from non-standard input field types. + */ +async function test_restore_nonstandard_input_values(aURL) { + // Add tab with various non-standard input field types. + let tab = BrowserTestUtils.addTab(gBrowser, aURL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Fill in form values. + let expectedValue = Math.random(); + + await SpecialPowers.spawn(browser, [expectedValue], valueChild => { + for (let elem of content.document.forms[0].elements) { + elem.value = valueChild; + let event = elem.ownerDocument.createEvent("UIEvents"); + event.initUIEvent("input", true, true, elem.ownerGlobal, 0); + elem.dispatchEvent(event); + } + }); + + // Remove tab and check collected form data. + await promiseRemoveTabAndSessionState(tab); + let undoItems = ss.getClosedTabData(window); + let savedFormData = undoItems[0].state.formdata; + + let foundIds = 0; + for (let id of Object.keys(savedFormData.id)) { + ok(EXPECTED_IDS.has(id), `Check saved ID "${id}" was expected`); + is( + savedFormData.id[id], + "" + expectedValue, + `Check saved value for #${id}` + ); + foundIds++; + } + + let foundXpaths = 0; + for (let exp of Object.keys(savedFormData.xpath)) { + ok(EXPECTED_XPATHS.has(exp), `Check saved xpath "${exp}" was expected`); + is( + savedFormData.xpath[exp], + "" + expectedValue, + `Check saved value for ${exp}` + ); + foundXpaths++; + } + + is(foundIds, EXPECTED_IDS.size, "Check number of fields saved by ID"); + is( + foundXpaths, + EXPECTED_XPATHS.size, + "Check number of fields saved by xpath" + ); +} diff --git a/browser/components/sessionstore/test/browser_456342_sample.xhtml b/browser/components/sessionstore/test/browser_456342_sample.xhtml new file mode 100644 index 0000000000..ea8704d17d --- /dev/null +++ b/browser/components/sessionstore/test/browser_456342_sample.xhtml @@ -0,0 +1,46 @@ +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" +"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> +<html xmlns="http://www.w3.org/1999/xhtml"> + +<head><title>Test for bug 456342</title></head> + +<body> +<form> +<h3>Non-standard <input>s</h3> +<p>Search <input type="search" id="searchTerm"/></p> +<p>Image Search: <input type="image search" /></p> +<p>Autocomplete: <input type="autocomplete" name="fill-in"/></p> +<p>Mistyped: <input type="txet" name="mistyped"/></p> +<p>Invalid attr: <textarea type="password" name="textarea_pass"/></p> + +<h3>Ignored types</h3> +<input type="hidden" name="hideme"/> +<input type="HIDDEN" name="hideme2"/> +<input type="submit" name="submit"/> +<input type="reset" name="reset"/> +<input type="image" name="image"/> +<input type="button" name="button"/> +<input type="password" name="password"/> +<input type="PassWord" name="password2"/> +<input type="PASSWORD" name="password3"/> +<input autocomplete="off" name="auto1"/> +<input type="text" autocomplete="OFF" name="auto2"/> +<input type="text" autocomplete=" OFF " name="auto5"/> +<input autocomplete=" off " name="auto6"/> +<input autocomplete=" cc-CSC " name="auto7"/> +<input autocomplete=" NEW-password " name="auto8"/> +<textarea autocomplete="off" name="auto3"/> +<select autocomplete="off" name="auto4"> + <option value="1" selected="true"/> + <option value="2"/> + <option value="3"/> +</select> +<select autocomplete="cc-CSC" name="CSC"> + <option value="123" selected="true"/> + <option value="234"/> + <option value="345"/> +</select> +</form> + +</body> +</html> diff --git a/browser/components/sessionstore/test/browser_459906.js b/browser/components/sessionstore/test/browser_459906.js new file mode 100644 index 0000000000..e232b59f13 --- /dev/null +++ b/browser/components/sessionstore/test/browser_459906.js @@ -0,0 +1,79 @@ +/* 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/. */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +function test() { + /** Test for Bug 459906 **/ + + waitForExplicitFinish(); + + let testURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_459906_sample.html"; + let uniqueValue = "<b>Unique:</b> " + Date.now(); + + var frameCount = 0; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + tab.linkedBrowser.addEventListener( + "load", + function listener(aEvent) { + // wait for all frames to load completely + if (frameCount++ < 2) { + return; + } + tab.linkedBrowser.removeEventListener("load", listener, true); + + let iframes = tab.linkedBrowser.contentWindow.frames; + // eslint-disable-next-line no-unsanitized/property + iframes[1].document.body.innerHTML = uniqueValue; + + frameCount = 0; + let tab2 = gBrowser.duplicateTab(tab); + tab2.linkedBrowser.addEventListener( + "load", + function loadListener(eventTab2) { + // wait for all frames to load (and reload!) completely + if (frameCount++ < 2) { + return; + } + tab2.linkedBrowser.removeEventListener("load", loadListener, true); + + executeSoon(function innerHTMLPoller() { + let iframesTab2 = tab2.linkedBrowser.contentWindow.frames; + if (iframesTab2[1].document.body.innerHTML !== uniqueValue) { + // Poll again the value, since we can't ensure to run + // after SessionStore has injected innerHTML value. + // See bug 521802. + info("Polling for innerHTML value"); + setTimeout(innerHTMLPoller, 100); + return; + } + + is( + iframesTab2[1].document.body.innerHTML, + uniqueValue, + "rich textarea's content correctly duplicated" + ); + + let innerDomain = null; + try { + innerDomain = iframesTab2[0].document.domain; + } catch (ex) { + /* throws for chrome: documents */ + } + is(innerDomain, "mochi.test", "XSS exploit prevented!"); + + // clean up + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab); + + finish(); + }); + }, + true + ); + }, + true + ); +} diff --git a/browser/components/sessionstore/test/browser_459906_empty.html b/browser/components/sessionstore/test/browser_459906_empty.html new file mode 100644 index 0000000000..e01aaa3394 --- /dev/null +++ b/browser/components/sessionstore/test/browser_459906_empty.html @@ -0,0 +1,3 @@ +<title>Cross Domain File for bug 459906</title> + +cheers from localhost diff --git a/browser/components/sessionstore/test/browser_459906_sample.html b/browser/components/sessionstore/test/browser_459906_sample.html new file mode 100644 index 0000000000..6f1c6a52ef --- /dev/null +++ b/browser/components/sessionstore/test/browser_459906_sample.html @@ -0,0 +1,41 @@ +<!-- Testcase originally by David Bloom <bloom@google.com> --> + +<!DOCTYPE html> +<title>Test for bug 459906</title> + +<body> +<iframe src="data:text/html;charset=utf-8,not_on_localhost"></iframe> +<iframe></iframe> + +<script type="application/javascript"> + var loadCount = 0; + frames[0].addEventListener("DOMContentLoaded", handleLoad); + frames[1].addEventListener("DOMContentLoaded", handleLoad); + function handleLoad() { + if (++loadCount < 2) + return; + frames[0].removeEventListener("DOMContentLoaded", handleLoad); + frames[1].removeEventListener("DOMContentLoaded", handleLoad); + frames[0].document.designMode = "on"; + frames[0].document.__defineGetter__("designMode", function() { + // inject a cross domain file ... + var documentInjected = false; + document.getElementsByTagName("iframe")[0].onload = + function() { documentInjected = true; }; + frames[0].location = "browser_459906_empty.html"; + + // ... and ensure that it has time to load + for (var c = 0; !documentInjected && c < 20; c++) { + var r = new XMLHttpRequest(); + r.open("GET", location.href, false); + r.overrideMimeType("text/plain"); + r.send(null); + } + + return "on"; + }); + + frames[1].document.designMode = "on"; + } +</script> +</body> diff --git a/browser/components/sessionstore/test/browser_461634.js b/browser/components/sessionstore/test/browser_461634.js new file mode 100644 index 0000000000..5de6b92c33 --- /dev/null +++ b/browser/components/sessionstore/test/browser_461634.js @@ -0,0 +1,137 @@ +/* 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/. */ + +const { SessionStore } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" +); + +add_task(async function testClosedTabData() { + /** Test for Bug 461634 **/ + + const REMEMBER = Date.now(), + FORGET = Math.random(); + let test_state = { + windows: [ + { + tabs: [{ entries: [] }], + _closedTabs: [ + { + state: { entries: [{ url: "http://www.example.net/" }] }, + title: FORGET, + }, + { + state: { entries: [{ url: "http://www.example.net/" }] }, + title: REMEMBER, + }, + { + state: { entries: [{ url: "http://www.example.net/" }] }, + title: FORGET, + }, + { + state: { entries: [{ url: "http://www.example.net/" }] }, + title: REMEMBER, + }, + ], + }, + ], + }; + let remember_count = 2; + + function countByTitle(aClosedTabList, aTitle) { + return aClosedTabList.filter(aData => aData.title == aTitle).length; + } + + function testForError(aFunction) { + try { + aFunction(); + return false; + } catch (ex) { + return ex.name == "NS_ERROR_ILLEGAL_VALUE"; + } + } + + // Open a window and add the above closed tab list. + let newWin = openDialog(location, "", "chrome,all,dialog=no"); + await promiseWindowLoaded(newWin); + + Services.prefs.setIntPref( + "browser.sessionstore.max_tabs_undo", + test_state.windows[0]._closedTabs.length + ); + await setWindowState(newWin, test_state); + + let closedTabs = SessionStore.getClosedTabData(newWin); + + // Verify that non JSON serialized data is the same as JSON serialized data. + is( + JSON.stringify(closedTabs), + JSON.stringify(SessionStore.getClosedTabData(newWin)), + "Non-serialized data is the same as serialized data" + ); + + is( + closedTabs.length, + test_state.windows[0]._closedTabs.length, + "Closed tab list has the expected length" + ); + is( + countByTitle(closedTabs, FORGET), + test_state.windows[0]._closedTabs.length - remember_count, + "The correct amout of tabs are to be forgotten" + ); + is( + countByTitle(closedTabs, REMEMBER), + remember_count, + "Everything is set up" + ); + + // All of the following calls with illegal arguments should throw NS_ERROR_ILLEGAL_VALUE. + ok( + testForError(() => ss.forgetClosedTab({}, 0)), + "Invalid window for forgetClosedTab throws" + ); + ok( + testForError(() => ss.forgetClosedTab(newWin, -1)), + "Invalid tab for forgetClosedTab throws" + ); + ok( + testForError(() => + ss.forgetClosedTab(newWin, test_state.windows[0]._closedTabs.length + 1) + ), + "Invalid tab for forgetClosedTab throws" + ); + + // Remove third tab, then first tab. + ss.forgetClosedTab(newWin, 2); + ss.forgetClosedTab(newWin, null); + + closedTabs = SessionStore.getClosedTabData(newWin); + + // Verify that non JSON serialized data is the same as JSON serialized data. + is( + JSON.stringify(closedTabs), + JSON.stringify(SessionStore.getClosedTabData(newWin)), + "Non-serialized data is the same as serialized data" + ); + + is( + closedTabs.length, + remember_count, + "The correct amout of tabs was removed" + ); + is( + countByTitle(closedTabs, FORGET), + 0, + "All tabs specifically forgotten were indeed removed" + ); + is( + countByTitle(closedTabs, REMEMBER), + remember_count, + "... and tabs not specifically forgetten weren't" + ); + + // Clean up. + Services.prefs.clearUserPref("browser.sessionstore.max_tabs_undo"); + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/sessionstore/test/browser_461743.js b/browser/components/sessionstore/test/browser_461743.js new file mode 100644 index 0000000000..32ad2b7ddb --- /dev/null +++ b/browser/components/sessionstore/test/browser_461743.js @@ -0,0 +1,53 @@ +/* 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/. */ + +function test() { + /** Test for Bug 461743 **/ + + waitForExplicitFinish(); + + let testURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_461743_sample.html"; + + let frameCount = 0; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + tab.linkedBrowser.addEventListener( + "load", + function loadListener(aEvent) { + // Wait for all frames to load completely. + if (frameCount++ < 2) { + return; + } + tab.linkedBrowser.removeEventListener("load", loadListener, true); + let tab2 = gBrowser.duplicateTab(tab); + tab2.linkedBrowser.addEventListener( + "461743", + function listener(eventTab2) { + tab2.linkedBrowser.removeEventListener("461743", listener, true); + is(aEvent.data, "done", "XSS injection was attempted"); + + executeSoon(function() { + let iframes = tab2.linkedBrowser.contentWindow.frames; + let innerHTML = iframes[1].document.body.innerHTML; + isnot( + innerHTML, + Cu.reportError.toString(), + "chrome access denied!" + ); + + // Clean up. + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab); + + finish(); + }); + }, + true, + true + ); + }, + true + ); +} diff --git a/browser/components/sessionstore/test/browser_461743_sample.html b/browser/components/sessionstore/test/browser_461743_sample.html new file mode 100644 index 0000000000..a933ec5dc9 --- /dev/null +++ b/browser/components/sessionstore/test/browser_461743_sample.html @@ -0,0 +1,56 @@ +<!-- Testcase originally by <moz_bug_r_a4@yahoo.com> --> + +<!DOCTYPE html> +<title>Test for bug 461743</title> + +<body> +<iframe src="data:text/html;charset=utf-8,empty"></iframe> +<iframe></iframe> + +<script type="application/javascript"> + var chromeUrl = "chrome://global/content/mozilla.html"; + var exploitUrl = "javascript:try { document.body.innerHTML = Components.utils.reportError; } catch (ex) { }"; + + var loadCount = 0; + frames[0].addEventListener("DOMContentLoaded", handleLoad); + frames[1].addEventListener("DOMContentLoaded", handleLoad); + function handleLoad() { + if (++loadCount < 2) + return; + frames[0].removeEventListener("DOMContentLoaded", handleLoad); + frames[1].removeEventListener("DOMContentLoaded", handleLoad); + + var flip = 0; + MutationEvent.prototype.toString = function() { + return flip++ == 0 ? chromeUrl : exploitUrl; + }; + + var href = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(frames[1].location), "href").get; + var loadChrome = { handleEvent: href }; + var loadExploit = { handleEvent: href }; + + function delay() { + var xhr = new XMLHttpRequest(); + xhr.open("GET", location.href, false); + xhr.send(null); + } + function done() { + var event = new MessageEvent("461743", { bubbles: true, cancelable: false, + data: "done", origin: location.href, + source: window }); + document.dispatchEvent(event); + frames[0].document.removeEventListener("DOMNodeInserted", loadChrome, true); + frames[0].document.removeEventListener("DOMNodeInserted", delay, true); + frames[0].document.removeEventListener("DOMNodeInserted", loadExploit, true); + frames[0].document.removeEventListener("DOMNodeInserted", done, true); + } + + frames[0].document.addEventListener("DOMNodeInserted", loadChrome, true); + frames[0].document.addEventListener("DOMNodeInserted", delay, true); + frames[0].document.addEventListener("DOMNodeInserted", loadExploit, true); + frames[0].document.addEventListener("DOMNodeInserted", done, true); + + frames[0].document.designMode = "on"; + } +</script> +</body> diff --git a/browser/components/sessionstore/test/browser_463205.js b/browser/components/sessionstore/test/browser_463205.js new file mode 100644 index 0000000000..797425ab05 --- /dev/null +++ b/browser/components/sessionstore/test/browser_463205.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = ROOT + "browser_463205_sample.html"; + +/** + * Bug 463205 - Check URLs before restoring form data to make sure a malicious + * website can't modify frame URLs and make us inject form data into the wrong + * web pages. + */ +add_task(async function test_check_urls_before_restoring() { + // Add a blank tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Restore form data with a valid URL. + await promiseTabState(tab, getState(URL)); + + let value = await getPropertyOfFormField(browser, "#text", "value"); + is(value, "foobar", "value was restored"); + + // Restore form data with an invalid URL. + await promiseTabState(tab, getState("http://example.com/")); + + value = await getPropertyOfFormField(browser, "#text", "value"); + is(value, "", "value was not restored"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +function getState(url) { + return JSON.stringify({ + entries: [{ url: URL, triggeringPrincipal_base64 }], + formdata: { url, id: { text: "foobar" } }, + }); +} diff --git a/browser/components/sessionstore/test/browser_463205_sample.html b/browser/components/sessionstore/test/browser_463205_sample.html new file mode 100644 index 0000000000..6591401b69 --- /dev/null +++ b/browser/components/sessionstore/test/browser_463205_sample.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<title>bug 463205</title> + +<body> + <input type="text" id="text" /> +</body> diff --git a/browser/components/sessionstore/test/browser_463206.js b/browser/components/sessionstore/test/browser_463206.js new file mode 100644 index 0000000000..58b7129963 --- /dev/null +++ b/browser/components/sessionstore/test/browser_463206.js @@ -0,0 +1,120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const MOCHI_ROOT = ROOT.replace( + "chrome://mochitests/content/", + "http://mochi.test:8888/" +); +if (gFissionBrowser) { + addCoopTask( + "browser_463206_sample.html", + test_restore_text_data_subframes, + HTTPSROOT + ); +} +addNonCoopTask( + "browser_463206_sample.html", + test_restore_text_data_subframes, + HTTPSROOT +); +addNonCoopTask( + "browser_463206_sample.html", + test_restore_text_data_subframes, + HTTPROOT +); +addNonCoopTask( + "browser_463206_sample.html", + test_restore_text_data_subframes, + MOCHI_ROOT +); + +async function test_restore_text_data_subframes(aURL) { + // Add a new tab. + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, aURL); + + await setPropertyOfFormField( + tab.linkedBrowser, + "#out1", + "value", + Date.now().toString(16) + ); + + await setPropertyOfFormField( + tab.linkedBrowser, + "input[name='1|#out2']", + "value", + Math.random() + ); + + await setPropertyOfFormField( + tab.linkedBrowser.browsingContext.children[0].children[1], + "#in1", + "value", + new Date() + ); + + // Duplicate the tab. + let tab2 = gBrowser.duplicateTab(tab); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + isnot( + await getPropertyOfFormField(browser2, "#out1", "value"), + await getPropertyOfFormField( + browser2.browsingContext.children[1], + "#out1", + "value" + ), + "text isn't reused for frames" + ); + + isnot( + await getPropertyOfFormField(browser2, "input[name='1|#out2']", "value"), + "", + "text containing | and # is correctly restored" + ); + + is( + await getPropertyOfFormField( + browser2.browsingContext.children[1], + "#out2", + "value" + ), + "", + "id prefixes can't be faked" + ); + + // Query a few values from the top and its child frames. + await SpecialPowers.spawn(tab2.linkedBrowser, [], async function() { + // Bug 588077 + // XXX(farre): disabling this, because it started passing more heavily on Windows. + /* + let in1ValFrame0_1 = await SpecialPowers.spawn( + content.frames[0], + [], + async function() { + return SpecialPowers.spawn(content.frames[1], [], async function() { + return content.document.getElementById("in1").value; + }); + } + ); + todo_is(in1ValFrame0_1, "", "id prefixes aren't mixed up"); + */ + }); + + is( + await getPropertyOfFormField( + browser2.browsingContext.children[1].children[0], + "#in1", + "value" + ), + "", + "id prefixes aren't mixed up" + ); + // Cleanup. + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab); +} diff --git a/browser/components/sessionstore/test/browser_463206_sample.html b/browser/components/sessionstore/test/browser_463206_sample.html new file mode 100644 index 0000000000..0d31f29066 --- /dev/null +++ b/browser/components/sessionstore/test/browser_463206_sample.html @@ -0,0 +1,11 @@ +<!-- Testcase originally by <moz_bug_r_a4@yahoo.com> --> + +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Test for bug 463206</title> + +<iframe src="data:text/html;charset=utf-8,<iframe></iframe><iframe%20src='data:text/html;charset=utf-8,<input%2520id=%2522in1%2522>'></iframe>"></iframe> +<iframe src="data:text/html;charset=utf-8,<input%20id='out1'><input%20id='out2'><iframe%20src='data:text/html;charset=utf-8,<input%2520id=%2522in1%2522>'>"></iframe> + +<input id="out1"> +<input name="1|#out2"> diff --git a/browser/components/sessionstore/test/browser_464199.js b/browser/components/sessionstore/test/browser_464199.js new file mode 100644 index 0000000000..f6ac4f5d33 --- /dev/null +++ b/browser/components/sessionstore/test/browser_464199.js @@ -0,0 +1,176 @@ +/* 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/. */ + +let { ForgetAboutSite } = ChromeUtils.import( + "resource://gre/modules/ForgetAboutSite.jsm" +); + +function promiseClearHistory() { + return new Promise(resolve => { + let observer = { + observe(aSubject, aTopic, aData) { + Services.obs.removeObserver( + this, + "browser:purge-session-history-for-domain" + ); + resolve(); + }, + }; + Services.obs.addObserver( + observer, + "browser:purge-session-history-for-domain" + ); + }); +} + +add_task(async function() { + /** Test for Bug 464199 **/ + + const REMEMBER = Date.now(), + FORGET = Math.random(); + let test_state = { + windows: [ + { + tabs: [{ entries: [] }], + _closedTabs: [ + { + state: { entries: [{ url: "http://www.example.net/" }] }, + title: FORGET, + }, + { + state: { entries: [{ url: "http://www.example.org/" }] }, + title: REMEMBER, + }, + { + state: { + entries: [ + { url: "http://www.example.net/" }, + { url: "http://www.example.org/" }, + ], + }, + title: FORGET, + }, + { + state: { entries: [{ url: "http://example.net/" }] }, + title: FORGET, + }, + { + state: { entries: [{ url: "http://sub.example.net/" }] }, + title: FORGET, + }, + { + state: { entries: [{ url: "http://www.example.net:8080/" }] }, + title: FORGET, + }, + { state: { entries: [{ url: "about:license" }] }, title: REMEMBER }, + { + state: { + entries: [ + { + url: "http://www.example.org/frameset", + children: [ + { url: "http://www.example.org/frame" }, + { url: "http://www.example.org:8080/frame2" }, + ], + }, + ], + }, + title: REMEMBER, + }, + { + state: { + entries: [ + { + url: "http://www.example.org/frameset", + children: [ + { url: "http://www.example.org/frame" }, + { url: "http://www.example.net/frame" }, + ], + }, + ], + }, + title: FORGET, + }, + { + state: { + entries: [ + { + url: "http://www.example.org/form", + formdata: { id: { url: "http://www.example.net/" } }, + }, + ], + }, + title: REMEMBER, + }, + { + state: { + entries: [{ url: "http://www.example.org/form" }], + extData: { setCustomTabValue: "http://example.net:80" }, + }, + title: REMEMBER, + }, + ], + }, + ], + }; + let remember_count = 5; + + function countByTitle(aClosedTabList, aTitle) { + return aClosedTabList.filter(aData => aData.title == aTitle).length; + } + + // open a window and add the above closed tab list + let newWin = openDialog(location, "", "chrome,all,dialog=no"); + await promiseWindowLoaded(newWin); + Services.prefs.setIntPref( + "browser.sessionstore.max_tabs_undo", + test_state.windows[0]._closedTabs.length + ); + + let restoring = promiseWindowRestoring(newWin); + let restored = promiseWindowRestored(newWin); + ss.setWindowState(newWin, JSON.stringify(test_state), true); + await restoring; + await restored; + + let closedTabs = ss.getClosedTabData(newWin); + is( + closedTabs.length, + test_state.windows[0]._closedTabs.length, + "Closed tab list has the expected length" + ); + is( + countByTitle(closedTabs, FORGET), + test_state.windows[0]._closedTabs.length - remember_count, + "The correct amout of tabs are to be forgotten" + ); + is( + countByTitle(closedTabs, REMEMBER), + remember_count, + "Everything is set up." + ); + + let promise = promiseClearHistory(); + await ForgetAboutSite.removeDataFromDomain("example.net"); + await promise; + closedTabs = ss.getClosedTabData(newWin); + is( + closedTabs.length, + remember_count, + "The correct amout of tabs was removed" + ); + is( + countByTitle(closedTabs, FORGET), + 0, + "All tabs to be forgotten were indeed removed" + ); + is( + countByTitle(closedTabs, REMEMBER), + remember_count, + "... and tabs to be remembered weren't." + ); + // clean up + Services.prefs.clearUserPref("browser.sessionstore.max_tabs_undo"); + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/sessionstore/test/browser_464620_a.html b/browser/components/sessionstore/test/browser_464620_a.html new file mode 100644 index 0000000000..5edb7e1a46 --- /dev/null +++ b/browser/components/sessionstore/test/browser_464620_a.html @@ -0,0 +1,54 @@ +<!-- Testcase originally by <moz_bug_r_a4@yahoo.com> --> + +<title>Test for bug 464620 (injection on input)</title> + +<iframe></iframe> +<iframe onload="setup()"></iframe> + +<script> + var targetUrl = "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_464620_xd.html"; + var firstPass; + + function setup() { + if (firstPass !== undefined) + return; + firstPass = frames[1].location.href == "about:blank"; + if (firstPass) { + frames[0].location = 'data:text/html;charset=utf-8,<body onload="if (parent.firstPass) parent.step();"><input id="x" oninput="parent.xss()">XXX</body>'; + } + frames[1].location = targetUrl; + } + + function step() { + var x = frames[0].document.getElementById("x"); + if (x.value == "") + x.value = "ready"; + x.style.display = "none"; + frames[0].document.designMode = "on"; + } + + function xss() { + step(); + + var documentInjected = false; + document.getElementsByTagName("iframe")[0].onload = + function() { documentInjected = true; }; + frames[0].location = targetUrl; + + for (var c = 0; !documentInjected && c < 20; c++) { + var r = new XMLHttpRequest(); + r.open("GET", location.href, false); + r.overrideMimeType("text/plain"); + r.send(null); + } + document.getElementById("state").textContent = "done"; + + var event = new MessageEvent("464620_a", { bubbles: true, cancelable: false, + data: "done", origin: location.href, + source: window }); + document.dispatchEvent(event); + } +</script> + +<p id="state">pending</p> diff --git a/browser/components/sessionstore/test/browser_464620_a.js b/browser/components/sessionstore/test/browser_464620_a.js new file mode 100644 index 0000000000..67356bc00a --- /dev/null +++ b/browser/components/sessionstore/test/browser_464620_a.js @@ -0,0 +1,64 @@ +/* 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/. */ + +function test() { + /** Test for Bug 464620 (injection on input) **/ + + waitForExplicitFinish(); + + let testURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_464620_a.html"; + + var frameCount = 0; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + tab.linkedBrowser.addEventListener( + "load", + function loadListener(aEvent) { + // wait for all frames to load completely + if (frameCount++ < 4) { + return; + } + this.removeEventListener("load", loadListener, true); + + executeSoon(function() { + frameCount = 0; + let tab2 = gBrowser.duplicateTab(tab); + tab2.linkedBrowser.addEventListener( + "464620_a", + function listener(eventTab2) { + tab2.linkedBrowser.removeEventListener("464620_a", listener, true); + is(aEvent.data, "done", "XSS injection was attempted"); + + // let form restoration complete and take into account the + // setTimeout(..., 0) in sss_restoreDocument_proxy + executeSoon(function() { + setTimeout(function() { + let win = tab2.linkedBrowser.contentWindow; + isnot( + win.frames[0].document.location, + testURL, + "cross domain document was loaded" + ); + ok( + !/XXX/.test(win.frames[0].document.body.innerHTML), + "no content was injected" + ); + + // clean up + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab); + + finish(); + }, 0); + }); + }, + true, + true + ); + }); + }, + true + ); +} diff --git a/browser/components/sessionstore/test/browser_464620_b.html b/browser/components/sessionstore/test/browser_464620_b.html new file mode 100644 index 0000000000..e7fde55c2b --- /dev/null +++ b/browser/components/sessionstore/test/browser_464620_b.html @@ -0,0 +1,57 @@ +<!-- Testcase originally by <moz_bug_r_a4@yahoo.com> --> + +<title>Test for bug 464620 (injection on DOM node insertion)</title> + +<iframe></iframe> +<iframe></iframe> +<iframe onload="setup()"></iframe> + +<script> + var targetUrl = "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_464620_xd.html"; + var firstPass; + + function setup() { + if (firstPass !== undefined) + return; + firstPass = frames[2].location.href == "about:blank"; + if (firstPass) { + frames[0].location = 'data:text/html;charset=utf-8,<body onload="parent.step()">a</body>'; + frames[1].location = 'data:text/html;charset=utf-8,<body onload="document.designMode=\'on\';">XXX</body>'; + } + frames[2].location = targetUrl; + } + + function step() { + frames[0].document.designMode = "on"; + if (firstPass) + return; + + var body = frames[0].document.body; + body.addEventListener("DOMNodeInserted", function() { + xss(); + }, {capture: true, once: true}); + } + + function xss() { + var documentInjected = false; + document.getElementsByTagName("iframe")[1].onload = + function() { documentInjected = true; }; + frames[1].location = targetUrl; + + for (var c = 0; !documentInjected && c < 20; c++) { + var r = new XMLHttpRequest(); + r.open("GET", location.href, false); + r.overrideMimeType("text/plain"); + r.send(null); + } + document.getElementById("state").textContent = "done"; + + var event = new MessageEvent("464620_b", { bubbles: true, cancelable: false, + data: "done", origin: location.href, + source: window }); + document.dispatchEvent(event); + } +</script> + +<p id="state">pending</p> diff --git a/browser/components/sessionstore/test/browser_464620_b.js b/browser/components/sessionstore/test/browser_464620_b.js new file mode 100644 index 0000000000..c8cc273b62 --- /dev/null +++ b/browser/components/sessionstore/test/browser_464620_b.js @@ -0,0 +1,64 @@ +/* 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/. */ + +function test() { + /** Test for Bug 464620 (injection on DOM node insertion) **/ + + waitForExplicitFinish(); + + let testURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_464620_b.html"; + + var frameCount = 0; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + tab.linkedBrowser.addEventListener( + "load", + function loadListener(aEvent) { + // wait for all frames to load completely + if (frameCount++ < 6) { + return; + } + this.removeEventListener("load", loadListener, true); + + executeSoon(function() { + frameCount = 0; + let tab2 = gBrowser.duplicateTab(tab); + tab2.linkedBrowser.addEventListener( + "464620_b", + function listener(eventTab2) { + tab2.linkedBrowser.removeEventListener("464620_b", listener, true); + is(aEvent.data, "done", "XSS injection was attempted"); + + // let form restoration complete and take into account the + // setTimeout(..., 0) in sss_restoreDocument_proxy + executeSoon(function() { + setTimeout(function() { + let win = tab2.linkedBrowser.contentWindow; + isnot( + win.frames[1].document.location, + testURL, + "cross domain document was loaded" + ); + ok( + !/XXX/.test(win.frames[1].document.body.innerHTML), + "no content was injected" + ); + + // clean up + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab); + + finish(); + }, 0); + }); + }, + true, + true + ); + }); + }, + true + ); +} diff --git a/browser/components/sessionstore/test/browser_464620_xd.html b/browser/components/sessionstore/test/browser_464620_xd.html new file mode 100644 index 0000000000..9ec51c4c7b --- /dev/null +++ b/browser/components/sessionstore/test/browser_464620_xd.html @@ -0,0 +1,5 @@ +<title>Cross Document File for bug 464620</title> + +<body onload="document.designMode='on';" bgcolor="red"> + This document is editable. +</body> diff --git a/browser/components/sessionstore/test/browser_465215.js b/browser/components/sessionstore/test/browser_465215.js new file mode 100644 index 0000000000..b3858c2644 --- /dev/null +++ b/browser/components/sessionstore/test/browser_465215.js @@ -0,0 +1,36 @@ +"use strict"; + +var uniqueName = "bug 465215"; +var uniqueValue1 = "as good as unique: " + Date.now(); +var uniqueValue2 = "as good as unique: " + Math.random(); + +add_task(async function() { + // set a unique value on a new, blank tab + let tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank"); + await promiseBrowserLoaded(tab1.linkedBrowser); + ss.setCustomTabValue(tab1, uniqueName, uniqueValue1); + + // duplicate the tab with that value + let tab2 = ss.duplicateTab(window, tab1); + await promiseTabRestored(tab2); + is( + ss.getCustomTabValue(tab2, uniqueName), + uniqueValue1, + "tab value was duplicated" + ); + + ss.setCustomTabValue(tab2, uniqueName, uniqueValue2); + isnot( + ss.getCustomTabValue(tab1, uniqueName), + uniqueValue2, + "tab values aren't sync'd" + ); + + // overwrite the tab with the value which should remove it + await promiseTabState(tab1, { entries: [] }); + is(ss.getCustomTabValue(tab1, uniqueName), "", "tab value was cleared"); + + // clean up + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab1); +}); diff --git a/browser/components/sessionstore/test/browser_465223.js b/browser/components/sessionstore/test/browser_465223.js new file mode 100644 index 0000000000..b6863e2634 --- /dev/null +++ b/browser/components/sessionstore/test/browser_465223.js @@ -0,0 +1,51 @@ +/* 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/. */ + +add_task(async function test_clearWindowValues() { + /** Test for Bug 465223 **/ + + let uniqueKey1 = "bug 465223.1"; + let uniqueKey2 = "bug 465223.2"; + let uniqueValue1 = "unik" + Date.now(); + let uniqueValue2 = "pi != " + Math.random(); + + // open a window and set a value on it + let newWin = openDialog(location, "_blank", "chrome,all,dialog=no"); + await promiseWindowLoaded(newWin); + ss.setCustomWindowValue(newWin, uniqueKey1, uniqueValue1); + + let newState = { windows: [{ tabs: [{ entries: [] }], extData: {} }] }; + newState.windows[0].extData[uniqueKey2] = uniqueValue2; + await setWindowState(newWin, newState); + + is(newWin.gBrowser.tabs.length, 2, "original tab wasn't overwritten"); + is( + ss.getCustomWindowValue(newWin, uniqueKey1), + uniqueValue1, + "window value wasn't overwritten when the tabs weren't" + ); + is( + ss.getCustomWindowValue(newWin, uniqueKey2), + uniqueValue2, + "new window value was correctly added" + ); + + newState.windows[0].extData[uniqueKey2] = uniqueValue1; + await setWindowState(newWin, newState, true); + + is(newWin.gBrowser.tabs.length, 1, "original tabs were overwritten"); + is( + ss.getCustomWindowValue(newWin, uniqueKey1), + "", + "window value was cleared" + ); + is( + ss.getCustomWindowValue(newWin, uniqueKey2), + uniqueValue1, + "window value was correctly overwritten" + ); + + // clean up + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/sessionstore/test/browser_466937.js b/browser/components/sessionstore/test/browser_466937.js new file mode 100644 index 0000000000..cca45fec3a --- /dev/null +++ b/browser/components/sessionstore/test/browser_466937.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = ROOT + "browser_466937_sample.html"; + +/** + * Bug 466937 - Prevent file stealing with sessionstore. + */ +add_task(async function test_prevent_file_stealing() { + // Add a tab with some file input fields. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Generate a path to a 'secret' file. + let file = Services.dirsvc.get("TmpD", Ci.nsIFile); + file.append("466937_test.file"); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o666); + let testPath = file.path; + + // Fill in form values. + await setPropertyOfFormField( + browser, + "#reverse_thief", + "value", + "/home/user/secret2" + ); + await setPropertyOfFormField(browser, "#bystander", "value", testPath); + + // Duplicate and check form values. + let tab2 = gBrowser.duplicateTab(tab); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + let thief = await getPropertyOfFormField(browser2, "#thief", "value"); + is(thief, "", "file path wasn't set to text field value"); + let reverse_thief = await getPropertyOfFormField( + browser2, + "#reverse_thief", + "value" + ); + is(reverse_thief, "", "text field value wasn't set to full file path"); + let bystander = await getPropertyOfFormField(browser2, "#bystander", "value"); + is(bystander, testPath, "normal case: file path was correctly preserved"); + + // Cleanup. + gBrowser.removeTab(tab); + gBrowser.removeTab(tab2); +}); diff --git a/browser/components/sessionstore/test/browser_466937_sample.html b/browser/components/sessionstore/test/browser_466937_sample.html new file mode 100644 index 0000000000..a05defa122 --- /dev/null +++ b/browser/components/sessionstore/test/browser_466937_sample.html @@ -0,0 +1,20 @@ +<!-- Testcase originally by <moz_bug_r_a4@yahoo.com> --> + +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Test for bug 466937</title> + +<input id="thief" value="/home/user/secret"> +<input type="file" id="reverse_thief"> +<input type="file" id="bystander"> + +<script> + window.addEventListener("DOMContentLoaded", function() { + if (!document.location.hash) { + document.location.hash = "#ready"; + } else { + document.getElementById("thief").type = "file"; + document.getElementById("reverse_thief").type = "text"; + } + }, {once: true}); +</script> diff --git a/browser/components/sessionstore/test/browser_467409-backslashplosion.js b/browser/components/sessionstore/test/browser_467409-backslashplosion.js new file mode 100644 index 0000000000..fe1a821160 --- /dev/null +++ b/browser/components/sessionstore/test/browser_467409-backslashplosion.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test Summary: +// 1. Open about:sessionrestore where formdata is a JS object, not a string +// 1a. Check that #sessionData on the page is readable after JSON.parse (skipped, checking formdata is sufficient) +// 1b. Check that there are no backslashes in the formdata +// 1c. Check that formdata doesn't require JSON.parse +// +// 2. Use the current state (currently about:sessionrestore with data) and then open that in a new instance of about:sessionrestore +// 2a. Check that there are no backslashes in the formdata +// 2b. Check that formdata doesn't require JSON.parse +// +// 3. [backwards compat] Use a stringified state as formdata when opening about:sessionrestore +// 3a. Make sure there are nodes in the tree on about:sessionrestore (skipped, checking formdata is sufficient) +// 3b. Check that there are no backslashes in the formdata +// 3c. Check that formdata doesn't require JSON.parse + +const CRASH_STATE = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }] }, + ], + }, + ], +}; +const STATE = createEntries(CRASH_STATE); +const STATE2 = createEntries({ windows: [{ tabs: [STATE] }] }); +const STATE3 = createEntries(JSON.stringify(CRASH_STATE)); + +function createEntries(sessionData) { + return { + entries: [{ url: "about:sessionrestore", triggeringPrincipal_base64 }], + formdata: { id: { sessionData }, url: "about:sessionrestore" }, + }; +} + +add_task(async function test_nested_about_sessionrestore() { + // Prepare a blank tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // test 1 + await promiseTabState(tab, STATE); + await checkState("test1", tab); + + // test 2 + await promiseTabState(tab, STATE2); + await checkState("test2", tab); + + // test 3 + await promiseTabState(tab, STATE3); + await checkState("test3", tab); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +async function checkState(prefix, tab) { + // Flush and query tab state. + await TabStateFlusher.flush(tab.linkedBrowser); + let { formdata } = JSON.parse(ss.getTabState(tab)); + + ok( + formdata.id.sessionData, + prefix + ": we have form data for about:sessionrestore" + ); + + let sessionData_raw = JSON.stringify(formdata.id.sessionData); + ok( + !/\\/.test(sessionData_raw), + prefix + ": #sessionData contains no backslashes" + ); + info(sessionData_raw); + + let gotError = false; + try { + JSON.parse(formdata.id.sessionData); + } catch (e) { + info(prefix + ": got error: " + e); + gotError = true; + } + ok(gotError, prefix + ": attempting to JSON.parse form data threw error"); +} diff --git a/browser/components/sessionstore/test/browser_477657.js b/browser/components/sessionstore/test/browser_477657.js new file mode 100644 index 0000000000..10f8fa761c --- /dev/null +++ b/browser/components/sessionstore/test/browser_477657.js @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +add_task(async function test_sizemodeDefaults() { + /** Test for Bug 477657 **/ + let newWin = openDialog(location, "_blank", "chrome,all,dialog=no"); + await promiseWindowLoaded(newWin); + let newState = { + windows: [ + { + tabs: [{ entries: [] }], + _closedTabs: [ + { + state: { entries: [{ url: "about:" }] }, + title: "About:", + }, + ], + sizemode: "maximized", + }, + ], + }; + + let uniqueKey = "bug 477657"; + let uniqueValue = "unik" + Date.now(); + + ss.setCustomWindowValue(newWin, uniqueKey, uniqueValue); + is( + ss.getCustomWindowValue(newWin, uniqueKey), + uniqueValue, + "window value was set before the window was overwritten" + ); + + await setWindowState(newWin, newState, true); + // use newWin.setTimeout(..., 0) to mirror sss_restoreWindowFeatures + await new Promise(resolve => newWin.setTimeout(resolve, 0)); + + is( + ss.getCustomWindowValue(newWin, uniqueKey), + "", + "window value was implicitly cleared" + ); + + is(newWin.windowState, newWin.STATE_MAXIMIZED, "the window was maximized"); + + is( + ss.getClosedTabData(newWin).length, + 1, + "the closed tab was added before the window was overwritten" + ); + delete newState.windows[0]._closedTabs; + delete newState.windows[0].sizemode; + + await setWindowState(newWin, newState, true); + await new Promise(resolve => newWin.setTimeout(resolve, 0)); + + is( + ss.getClosedTabData(newWin).length, + 0, + "closed tabs were implicitly cleared" + ); + + is( + newWin.windowState, + newWin.STATE_MAXIMIZED, + "the window remains maximized" + ); + newState.windows[0].sizemode = "normal"; + + await setWindowState(newWin, newState, true); + await new Promise(resolve => newWin.setTimeout(resolve, 0)); + + isnot( + newWin.windowState, + newWin.STATE_MAXIMIZED, + "the window was explicitly unmaximized" + ); + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/sessionstore/test/browser_480893.js b/browser/components/sessionstore/test/browser_480893.js new file mode 100644 index 0000000000..3b2816da7d --- /dev/null +++ b/browser/components/sessionstore/test/browser_480893.js @@ -0,0 +1,45 @@ +"use strict"; + +/** + * Tests that we get sent to the right page when the user clicks + * the "Close" button in about:sessionrestore + */ +add_task(async function() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.startup.page", 0]], + }); + + let tab = BrowserTestUtils.addTab(gBrowser, "about:sessionrestore"); + gBrowser.selectedTab = tab; + let browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser, false, "about:sessionrestore"); + + let doc = browser.contentDocument; + + // Click on the "Close" button after about:sessionrestore is loaded. + doc.getElementById("errorCancel").click(); + + await BrowserTestUtils.browserLoaded(browser, false, "about:blank"); + + // Test that starting a new session loads the homepage (set to http://mochi.test:8888) + // if Firefox is configured to display a homepage at startup (browser.startup.page = 1) + let homepage = "http://mochi.test:8888/"; + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.startup.homepage", homepage], + ["browser.startup.page", 1], + ], + }); + + BrowserTestUtils.loadURI(browser, "about:sessionrestore"); + await BrowserTestUtils.browserLoaded(browser, false, "about:sessionrestore"); + doc = browser.contentDocument; + + // Click on the "Close" button after about:sessionrestore is loaded. + doc.getElementById("errorCancel").click(); + await BrowserTestUtils.browserLoaded(browser); + + is(browser.currentURI.spec, homepage, "loaded page is the homepage"); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_485482.js b/browser/components/sessionstore/test/browser_485482.js new file mode 100644 index 0000000000..f32523005b --- /dev/null +++ b/browser/components/sessionstore/test/browser_485482.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +if (gFissionBrowser) { + addCoopTask( + "browser_485482_sample.html", + test_xpath_exp_for_strange_documents, + HTTPSROOT + ); +} +addNonCoopTask( + "browser_485482_sample.html", + test_xpath_exp_for_strange_documents, + ROOT +); +addNonCoopTask( + "browser_485482_sample.html", + test_xpath_exp_for_strange_documents, + HTTPSROOT +); +addNonCoopTask( + "browser_485482_sample.html", + test_xpath_exp_for_strange_documents, + HTTPROOT +); + +/** + * Bug 485482 - Make sure that we produce valid XPath expressions even for very + * weird HTML documents. + */ +async function test_xpath_exp_for_strange_documents(aURL) { + // Load a page with weird tag names. + let tab = BrowserTestUtils.addTab(gBrowser, aURL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Fill in some values. + let uniqueValue = Math.random(); + await setPropertyOfFormField( + browser, + "input[type=text]", + "value", + uniqueValue + ); + await setPropertyOfFormField( + browser, + "input[type=checkbox]", + "checked", + true + ); + + // Duplicate the tab. + let tab2 = gBrowser.duplicateTab(tab); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + // Check that we generated valid XPath expressions to restore form values. + let text = await getPropertyOfFormField( + browser2, + "input[type=text]", + "value" + ); + is("" + text, "" + uniqueValue, "generated XPath expression was valid"); + let checkbox = await getPropertyOfFormField( + browser2, + "input[type=checkbox]", + "checked" + ); + ok(checkbox, "generated XPath expression was valid"); + + // Cleanup. + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab); +} diff --git a/browser/components/sessionstore/test/browser_485482_sample.html b/browser/components/sessionstore/test/browser_485482_sample.html new file mode 100644 index 0000000000..c2097b5930 --- /dev/null +++ b/browser/components/sessionstore/test/browser_485482_sample.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<title>Test for bug 485482</title> + +<bad=name> + <input type="text"> +</bad=name> + +<worse=name> + <l0c@l+na~e"'§> + <input type="checkbox" name="check"> Check + </l0c@l+na~e"'§> +</worse=name> diff --git a/browser/components/sessionstore/test/browser_485563.js b/browser/components/sessionstore/test/browser_485563.js new file mode 100644 index 0000000000..797100dd98 --- /dev/null +++ b/browser/components/sessionstore/test/browser_485563.js @@ -0,0 +1,33 @@ +/* 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/. */ + +function test() { + /** Test for Bug 485563 **/ + + waitForExplicitFinish(); + + let uniqueValue = + Math.random() + "\u2028Second line\u2029Second paragraph\u2027"; + + let tab = BrowserTestUtils.addTab(gBrowser); + promiseBrowserLoaded(tab.linkedBrowser).then(() => { + ss.setCustomTabValue(tab, "bug485563", uniqueValue); + let tabState = JSON.parse(ss.getTabState(tab)); + is( + tabState.extData.bug485563, + uniqueValue, + "unicode line separator wasn't over-encoded" + ); + ss.deleteCustomTabValue(tab, "bug485563"); + ss.setTabState(tab, JSON.stringify(tabState)); + is( + ss.getCustomTabValue(tab, "bug485563"), + uniqueValue, + "unicode line separator was correctly preserved" + ); + + gBrowser.removeTab(tab); + finish(); + }); +} diff --git a/browser/components/sessionstore/test/browser_490040.js b/browser/components/sessionstore/test/browser_490040.js new file mode 100644 index 0000000000..623a9ea0ab --- /dev/null +++ b/browser/components/sessionstore/test/browser_490040.js @@ -0,0 +1,105 @@ +/* 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/. */ + +// Only windows with open tabs are restorable. Windows where a lone tab is +// detached may have _closedTabs, but is left with just an empty tab. +const STATES = [ + { + shouldBeAdded: true, + windowState: { + windows: [ + { + tabs: [ + { + entries: [ + { + url: "http://example.com", + triggeringPrincipal_base64, + title: "example.com", + }, + ], + }, + ], + selected: 1, + _closedTabs: [], + }, + ], + }, + }, + { + shouldBeAdded: false, + windowState: { + windows: [ + { + tabs: [{ entries: [] }], + _closedTabs: [], + }, + ], + }, + }, + { + shouldBeAdded: false, + windowState: { + windows: [ + { + tabs: [{ entries: [] }], + _closedTabs: [ + { + state: { + entries: [ + { + url: "http://example.com", + triggeringPrincipal_base64, + index: 1, + }, + ], + }, + }, + ], + }, + ], + }, + }, + { + shouldBeAdded: false, + windowState: { + windows: [ + { + tabs: [{ entries: [] }], + _closedTabs: [], + extData: { keyname: "pi != " + Math.random() }, + }, + ], + }, + }, +]; + +add_task(async function test_bug_490040() { + for (let state of STATES) { + // Ensure we can store the window if needed. + let startingClosedWindowCount = ss.getClosedWindowCount(); + await pushPrefs([ + "browser.sessionstore.max_windows_undo", + startingClosedWindowCount + 1, + ]); + + let curClosedWindowCount = ss.getClosedWindowCount(); + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await setWindowState(win, state.windowState, true); + if (state.windowState.windows[0].tabs.length) { + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + } + + await BrowserTestUtils.closeWindow(win); + + is( + ss.getClosedWindowCount(), + curClosedWindowCount + (state.shouldBeAdded ? 1 : 0), + "That window should " + + (state.shouldBeAdded ? "" : "not ") + + "be restorable" + ); + } +}); diff --git a/browser/components/sessionstore/test/browser_491168.js b/browser/components/sessionstore/test/browser_491168.js new file mode 100644 index 0000000000..b8016e68de --- /dev/null +++ b/browser/components/sessionstore/test/browser_491168.js @@ -0,0 +1,113 @@ +"use strict"; + +const REFERRER1 = "http://example.org/?" + Date.now(); +const REFERRER2 = "http://example.org/?" + Math.random(); +const REFERRER3 = "http://example.org/?" + Math.random(); + +add_task(async function() { + function getExpectedReferrer(referrer) { + let defaultPolicy = Services.prefs.getIntPref( + "network.http.referer.defaultPolicy" + ); + ok( + [2, 3].indexOf(defaultPolicy) > -1, + "default referrer policy should be either strict-origin-when-cross-origin(2) or no-referrer-when-downgrade(3)" + ); + if (defaultPolicy == 2) { + return referrer.match(/https?:\/\/[^\/]+\/?/i)[0]; + } + return referrer; + } + + async function checkDocumentReferrer(referrer, msg) { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [{ referrer, msg }], + async function(args) { + Assert.equal(content.document.referrer, args.referrer, args.msg); + } + ); + } + + let ReferrerInfo = Components.Constructor( + "@mozilla.org/referrer-info;1", + "nsIReferrerInfo", + "init" + ); + // Add a new tab. + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:blank" + )); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Load a new URI with a specific referrer. + let referrerInfo1 = new ReferrerInfo( + Ci.nsIReferrerInfo.EMPTY, + true, + Services.io.newURI(REFERRER1) + ); + browser.loadURI("http://example.org", { + referrerInfo: referrerInfo1, + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}), + }); + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + let tabState = JSON.parse(ss.getTabState(tab)); + let actualReferrerInfo = E10SUtils.deserializeReferrerInfo( + tabState.entries[0].referrerInfo + ); + is( + actualReferrerInfo.originalReferrer.spec, + REFERRER1, + "Referrer retrieved via getTabState matches referrer set via loadURI." + ); + + let referrerInfo2 = new ReferrerInfo( + Ci.nsIReferrerInfo.EMPTY, + true, + Services.io.newURI(REFERRER2) + ); + + tabState.entries[0].referrerInfo = E10SUtils.serializeReferrerInfo( + referrerInfo2 + ); + await promiseTabState(tab, tabState); + + await checkDocumentReferrer( + getExpectedReferrer(REFERRER2), + "document.referrer matches referrer set via setTabState using referrerInfo." + ); + gBrowser.removeCurrentTab(); + + // Restore the closed tab. + tab = ss.undoCloseTab(window, 0); + await promiseTabRestored(tab); + + await checkDocumentReferrer( + getExpectedReferrer(REFERRER2), + "document.referrer is still correct after closing and reopening the tab." + ); + + tabState.entries[0].referrerInfo = null; + tabState.entries[0].referrer = REFERRER3; + await promiseTabState(tab, tabState); + + await checkDocumentReferrer( + getExpectedReferrer(REFERRER3), + "document.referrer matches referrer set via setTabState using referrer." + ); + gBrowser.removeCurrentTab(); + + // Restore the closed tab. + tab = ss.undoCloseTab(window, 0); + await promiseTabRestored(tab); + + await checkDocumentReferrer( + getExpectedReferrer(REFERRER3), + "document.referrer is still correct after closing and reopening the tab." + ); + gBrowser.removeCurrentTab(); +}); diff --git a/browser/components/sessionstore/test/browser_491577.js b/browser/components/sessionstore/test/browser_491577.js new file mode 100644 index 0000000000..87d77b71c4 --- /dev/null +++ b/browser/components/sessionstore/test/browser_491577.js @@ -0,0 +1,212 @@ +/* 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/. */ + +add_task(async function test_deleteClosedWindow() { + /** Test for Bug 491577 **/ + + const REMEMBER = Date.now(), + FORGET = Math.random(); + let test_state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.com/", triggeringPrincipal_base64 }, + ], + }, + ], + selected: 1, + }, + ], + _closedWindows: [ + // _closedWindows[0] + { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + ], + selected: 2, + title: FORGET, + _closedTabs: [], + }, + // _closedWindows[1] + { + tabs: [ + { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + ], + selected: 3, + title: REMEMBER, + _closedTabs: [], + }, + // _closedWindows[2] + { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + ], + selected: 1, + title: FORGET, + _closedTabs: [ + { + state: { + entries: [ + { + url: "http://mozilla.org/", + triggeringPrincipal_base64, + title: "title", + }, + { + url: "http://mozilla.org/again", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + pos: 1, + title: "title", + }, + { + state: { + entries: [ + { + url: "http://example.com", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + title: "title", + }, + ], + }, + ], + }; + let remember_count = 1; + + function countByTitle(aClosedWindowList, aTitle) { + return aClosedWindowList.filter(aData => aData.title == aTitle).length; + } + + function testForError(aFunction) { + try { + aFunction(); + return false; + } catch (ex) { + return ex.name == "NS_ERROR_ILLEGAL_VALUE"; + } + } + + // open a window and add the above closed window list + let newWin = openDialog(location, "_blank", "chrome,all,dialog=no"); + await promiseWindowLoaded(newWin); + Services.prefs.setIntPref( + "browser.sessionstore.max_windows_undo", + test_state._closedWindows.length + ); + await setWindowState(newWin, test_state, true); + + let closedWindows = ss.getClosedWindowData(); + is( + closedWindows.length, + test_state._closedWindows.length, + "Closed window list has the expected length" + ); + is( + countByTitle(closedWindows, FORGET), + test_state._closedWindows.length - remember_count, + "The correct amount of windows are to be forgotten" + ); + is( + countByTitle(closedWindows, REMEMBER), + remember_count, + "Everything is set up." + ); + + // all of the following calls with illegal arguments should throw NS_ERROR_ILLEGAL_VALUE + ok( + testForError(() => ss.forgetClosedWindow(-1)), + "Invalid window for forgetClosedWindow throws" + ); + ok( + testForError(() => + ss.forgetClosedWindow(test_state._closedWindows.length + 1) + ), + "Invalid window for forgetClosedWindow throws" + ); + + // Remove third window, then first window + ss.forgetClosedWindow(2); + ss.forgetClosedWindow(null); + + closedWindows = ss.getClosedWindowData(); + is( + closedWindows.length, + remember_count, + "The correct amount of windows were removed" + ); + is( + countByTitle(closedWindows, FORGET), + 0, + "All windows specifically forgotten were indeed removed" + ); + is( + countByTitle(closedWindows, REMEMBER), + remember_count, + "... and windows not specifically forgetten weren't." + ); + + // clean up + Services.prefs.clearUserPref("browser.sessionstore.max_windows_undo"); + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/sessionstore/test/browser_495495.js b/browser/components/sessionstore/test/browser_495495.js new file mode 100644 index 0000000000..d8779d2f05 --- /dev/null +++ b/browser/components/sessionstore/test/browser_495495.js @@ -0,0 +1,47 @@ +/* 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/. */ + +add_task(async function test_urlbarFocus() { + /** Test for Bug 495495 **/ + + let newWin = openDialog( + location, + "_blank", + "chrome,all,dialog=no,toolbar=yes" + ); + await promiseWindowLoaded(newWin); + let state1 = ss.getWindowState(newWin); + await BrowserTestUtils.closeWindow(newWin); + + newWin = openDialog( + location, + "_blank", + "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar=no,location,personal,directories,dialog=no" + ); + await promiseWindowLoaded(newWin); + let state2 = ss.getWindowState(newWin); + + async function testState(state, expected) { + let win = openDialog(location, "_blank", "chrome,all,dialog=no"); + await promiseWindowLoaded(win); + + is( + win.gURLBar.readOnly, + false, + "URL bar should not be read-only before setting the state" + ); + await setWindowState(win, state, true); + is( + win.gURLBar.readOnly, + expected.readOnly, + "URL bar read-only state should be restored correctly" + ); + + await BrowserTestUtils.closeWindow(win); + } + + await BrowserTestUtils.closeWindow(newWin); + await testState(state1, { readOnly: false }); + await testState(state2, { readOnly: true }); +}); diff --git a/browser/components/sessionstore/test/browser_500328.js b/browser/components/sessionstore/test/browser_500328.js new file mode 100644 index 0000000000..b522c37eb2 --- /dev/null +++ b/browser/components/sessionstore/test/browser_500328.js @@ -0,0 +1,132 @@ +/* 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/. */ + +async function checkState(browser) { + await SpecialPowers.spawn(browser, [], () => { + // Go back and then forward, and make sure that the state objects received + // from the popState event are as we expect them to be. + // + // We also add a node to the document's body when after going back and make + // sure it's still there after we go forward -- this is to test that the two + // history entries correspond to the same document. + + // Set some state in the page's window. When we go back(), the page should + // be retrieved from bfcache, and this state should still be there. + content.testState = "foo"; + }); + + // Now go back. This should trigger the popstate event handler. + let popstatePromise = SpecialPowers.spawn(browser, [], async () => { + let event = await ContentTaskUtils.waitForEvent(content, "popstate", true); + ok(event.state, "Event should have a state property."); + + is(content.testState, "foo", "testState after going back"); + is( + JSON.stringify(content.history.state), + JSON.stringify({ obj1: 1 }), + "first popstate object." + ); + + // Add a node with id "new-elem" to the document. + let doc = content.document; + ok( + !doc.getElementById("new-elem"), + "doc shouldn't contain new-elem before we add it." + ); + let elem = doc.createElement("div"); + elem.id = "new-elem"; + doc.body.appendChild(elem); + }); + + // Ensure that the message manager has processed the previous task before + // going back to prevent racing with it in non-e10s mode. + await SpecialPowers.spawn(browser, [], () => {}); + browser.goBack(); + + await popstatePromise; + + popstatePromise = SpecialPowers.spawn(browser, [], async () => { + let event = await ContentTaskUtils.waitForEvent(content, "popstate", true); + + // When content fires a PopStateEvent and we observe it from a chrome event + // listener (as we do here, and, thankfully, nowhere else in the tree), the + // state object will be a cross-compartment wrapper to an object that was + // deserialized in the content scope. And in this case, since RegExps are + // not currently Xrayable (see bug 1014991), trying to pull |obj3| (a RegExp) + // off of an Xrayed Object won't work. So we need to waive. + Assert.equal( + Cu.waiveXrays(event.state).obj3.toString(), + "/^a$/", + "second popstate object." + ); + + // Make sure that the new-elem node is present in the document. If it's + // not, then this history entry has a different doc identifier than the + // previous entry, which is bad. + let doc = content.document; + let newElem = doc.getElementById("new-elem"); + ok(newElem, "doc should contain new-elem."); + newElem.remove(); + ok(!doc.getElementById("new-elem"), "new-elem should be removed."); + }); + + // Ensure that the message manager has processed the previous task before + // going forward to prevent racing with it in non-e10s mode. + await SpecialPowers.spawn(browser, [], () => {}); + browser.goForward(); + await popstatePromise; +} + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + + // Tests session restore functionality of history.pushState and + // history.replaceState(). (Bug 500328) + + // We open a new blank window, let it load, and then load in + // http://example.com. We need to load the blank window first, otherwise the + // docshell gets confused and doesn't have a current history entry. + let state; + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async function(browser) { + BrowserTestUtils.loadURI(browser, "http://example.com"); + await BrowserTestUtils.browserLoaded(browser); + + // After these push/replaceState calls, the window should have three + // history entries: + // testURL (state object: null) <-- oldest + // testURL (state object: {obj1:1}) + // testURL?page2 (state object: {obj3:/^a$/}) <-- newest + function contentTest() { + let history = content.window.history; + history.pushState({ obj1: 1 }, "title-obj1"); + history.pushState({ obj2: 2 }, "title-obj2", "?page2"); + history.replaceState({ obj3: /^a$/ }, "title-obj3"); + } + await SpecialPowers.spawn(browser, [], contentTest); + await TabStateFlusher.flush(browser); + + state = ss.getTabState(gBrowser.getTabForBrowser(browser)); + } + ); + + // Restore the state into a new tab. Things don't work well when we + // restore into the old tab, but that's not a real use case anyway. + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:blank" }, + async function(browser) { + let tab2 = gBrowser.getTabForBrowser(browser); + + let tabRestoredPromise = promiseTabRestored(tab2); + ss.setTabState(tab2, state, true); + + // Run checkState() once the tab finishes loading its restored state. + await tabRestoredPromise; + await checkState(browser); + } + ); +}); diff --git a/browser/components/sessionstore/test/browser_506482.js b/browser/components/sessionstore/test/browser_506482.js new file mode 100644 index 0000000000..a8e628ff7d --- /dev/null +++ b/browser/components/sessionstore/test/browser_506482.js @@ -0,0 +1,78 @@ +/* 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/. */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +function test() { + /** Test for Bug 506482 **/ + + // test setup + waitForExplicitFinish(); + + // read the sessionstore.js mtime (picked from browser_248970_a.js) + let profilePath = Services.dirsvc.get("ProfD", Ci.nsIFile); + function getSessionstoreFile() { + let sessionStoreJS = profilePath.clone(); + sessionStoreJS.append("sessionstore.jsonlz4"); + return sessionStoreJS; + } + function getSessionstorejsModificationTime() { + let file = getSessionstoreFile(); + if (file.exists()) { + return file.lastModifiedTime; + } + return -1; + } + + // delete existing sessionstore.js, to make sure we're not reading + // the mtime of an old one initially. + let sessionStoreJS = getSessionstoreFile(); + if (sessionStoreJS.exists()) { + sessionStoreJS.remove(false); + } + + // test content URL + const TEST_URL = + "data:text/html;charset=utf-8," + + "<body style='width: 100000px; height: 100000px;'><p>top</p></body>"; + + // preferences that we use + const PREF_INTERVAL = "browser.sessionstore.interval"; + + // make sure sessionstore.js is saved ASAP on all events + Services.prefs.setIntPref(PREF_INTERVAL, 0); + + // get the initial sessionstore.js mtime (-1 if it doesn't exist yet) + let mtime0 = getSessionstorejsModificationTime(); + + // create and select a first tab + let tab = BrowserTestUtils.addTab(gBrowser, TEST_URL); + promiseBrowserLoaded(tab.linkedBrowser).then(() => { + // step1: the above has triggered some saveStateDelayed(), sleep until + // it's done, and get the initial sessionstore.js mtime + setTimeout(function step1() { + let mtime1 = getSessionstorejsModificationTime(); + isnot(mtime1, mtime0, "initial sessionstore.js update"); + + // step2: test sessionstore.js is not updated on tab selection + // or content scrolling + gBrowser.selectedTab = tab; + tab.linkedBrowser.contentWindow.scrollTo(1100, 1200); + setTimeout(function step2() { + let mtime2 = getSessionstorejsModificationTime(); + is( + mtime2, + mtime1, + "tab selection and scrolling: sessionstore.js not updated" + ); + + // ok, done, cleanup and finish + if (Services.prefs.prefHasUserValue(PREF_INTERVAL)) { + Services.prefs.clearUserPref(PREF_INTERVAL); + } + gBrowser.removeTab(tab); + finish(); + }, 3500); // end of sleep after tab selection and scrolling + }, 3500); // end of sleep after initial saveStateDelayed() + }); +} diff --git a/browser/components/sessionstore/test/browser_514751.js b/browser/components/sessionstore/test/browser_514751.js new file mode 100644 index 0000000000..96bf9b08c8 --- /dev/null +++ b/browser/components/sessionstore/test/browser_514751.js @@ -0,0 +1,41 @@ +/* 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/. */ + +add_task(async function test_malformedURI() { + /** Test for Bug 514751 (Wallpaper) **/ + + let state = { + windows: [ + { + tabs: [ + { + entries: [ + { + url: "about:mozilla", + triggeringPrincipal_base64, + title: "Mozilla", + }, + {}, + ], + }, + ], + }, + ], + }; + + var theWin = openDialog(location, "", "chrome,all,dialog=no"); + await promiseWindowLoaded(theWin); + + var gotError = false; + try { + await setWindowState(theWin, state, true); + } catch (e) { + if (/NS_ERROR_MALFORMED_URI/.test(e)) { + gotError = true; + } + } + + ok(!gotError, "Didn't get a malformed URI error."); + await BrowserTestUtils.closeWindow(theWin); +}); diff --git a/browser/components/sessionstore/test/browser_522375.js b/browser/components/sessionstore/test/browser_522375.js new file mode 100644 index 0000000000..ccc8f9dc64 --- /dev/null +++ b/browser/components/sessionstore/test/browser_522375.js @@ -0,0 +1,22 @@ +function test() { + var startup_info = Services.startup.getStartupInfo(); + // No .process info on mac + + ok( + startup_info.process <= startup_info.main, + "process created before main is run " + uneval(startup_info) + ); + + // on linux firstPaint can happen after everything is loaded (especially with remote X) + if (startup_info.firstPaint) { + ok( + startup_info.main <= startup_info.firstPaint, + "main ran before first paint " + uneval(startup_info) + ); + } + + ok( + startup_info.main < startup_info.sessionRestored, + "Session restored after main " + uneval(startup_info) + ); +} diff --git a/browser/components/sessionstore/test/browser_522545.js b/browser/components/sessionstore/test/browser_522545.js new file mode 100644 index 0000000000..3761b160ec --- /dev/null +++ b/browser/components/sessionstore/test/browser_522545.js @@ -0,0 +1,443 @@ +/* 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/. */ + +function test() { + /** Test for Bug 522545 **/ + + waitForExplicitFinish(); + requestLongerTimeout(4); + + // This tests the following use case: + // User opens a new tab which gets focus. The user types something into the + // address bar, then crashes or quits. + function test_newTabFocused() { + let state = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }] }, + { entries: [], userTypedValue: "example.com", userTypedClear: 0 }, + ], + selected: 2, + }, + ], + }; + + waitForBrowserState(state, function() { + let browser = gBrowser.selectedBrowser; + is( + browser.currentURI.spec, + "about:blank", + "No history entries still sets currentURI to about:blank" + ); + is( + browser.userTypedValue, + "example.com", + "userTypedValue was correctly restored" + ); + ok( + !browser.didStartLoadSinceLastUserTyping(), + "We still know that no load is ongoing" + ); + is( + gURLBar.value, + "example.com", + "Address bar's value correctly restored" + ); + + // Change tabs to make sure address bar value gets updated. If tab is + // lazy, wait for SSTabRestored to ensure address bar has time to update. + let tabToSelect = gBrowser.tabContainer.getItemAtIndex(0); + if (tabToSelect.linkedBrowser.isConnected) { + gBrowser.selectedTab = tabToSelect; + is( + gURLBar.value, + "about:mozilla", + "Address bar's value correctly updated" + ); + runNextTest(); + } else { + gBrowser.tabContainer.addEventListener( + "SSTabRestored", + function SSTabRestored(event) { + if (event.target == tabToSelect) { + gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + SSTabRestored, + true + ); + is( + gURLBar.value, + "about:mozilla", + "Address bar's value correctly updated" + ); + runNextTest(); + } + }, + true + ); + gBrowser.selectedTab = tabToSelect; + } + }); + } + + // This tests the following use case: + // User opens a new tab which gets focus. The user types something into the + // address bar, switches back to the first tab, then crashes or quits. + function test_newTabNotFocused() { + let state = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }] }, + { entries: [], userTypedValue: "example.org", userTypedClear: 0 }, + ], + selected: 1, + }, + ], + }; + + waitForBrowserState(state, function() { + let browser = gBrowser.getBrowserAtIndex(1); + is( + browser.currentURI.spec, + "about:blank", + "No history entries still sets currentURI to about:blank" + ); + is( + browser.userTypedValue, + "example.org", + "userTypedValue was correctly restored" + ); + // didStartLoadSinceLastUserTyping does not exist on lazy tabs. + if (browser.didStartLoadSinceLastUserTyping) { + ok( + !browser.didStartLoadSinceLastUserTyping(), + "We still know that no load is ongoing" + ); + } + is( + gURLBar.value, + "about:mozilla", + "Address bar's value correctly restored" + ); + + // Change tabs to make sure address bar value gets updated. If tab is + // lazy, wait for SSTabRestored to ensure address bar has time to update. + let tabToSelect = gBrowser.tabContainer.getItemAtIndex(1); + if (tabToSelect.linkedBrowser.isConnected) { + gBrowser.selectedTab = tabToSelect; + is( + gURLBar.value, + "example.org", + "Address bar's value correctly updated" + ); + runNextTest(); + } else { + gBrowser.tabContainer.addEventListener( + "SSTabRestored", + function SSTabRestored(event) { + if (event.target == tabToSelect) { + gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + SSTabRestored, + true + ); + is( + gURLBar.value, + "example.org", + "Address bar's value correctly updated" + ); + runNextTest(); + } + }, + true + ); + gBrowser.selectedTab = tabToSelect; + } + }); + } + + // This tests the following use case: + // User is in a tab with session history, then types something in the + // address bar, then crashes or quits. + function test_existingSHEnd_noClear() { + let state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "about:mozilla", triggeringPrincipal_base64 }, + { url: "about:config", triggeringPrincipal_base64 }, + ], + index: 2, + userTypedValue: "example.com", + userTypedClear: 0, + }, + ], + }, + ], + }; + + waitForBrowserState(state, function() { + let browser = gBrowser.selectedBrowser; + is( + browser.currentURI.spec, + "about:config", + "browser.currentURI set to current entry in SH" + ); + is( + browser.userTypedValue, + "example.com", + "userTypedValue was correctly restored" + ); + ok( + !browser.didStartLoadSinceLastUserTyping(), + "We still know that no load is ongoing" + ); + is( + gURLBar.value, + "example.com", + "Address bar's value correctly restored to userTypedValue" + ); + runNextTest(); + }); + } + + // This tests the following use case: + // User is in a tab with session history, presses back at some point, then + // types something in the address bar, then crashes or quits. + function test_existingSHMiddle_noClear() { + let state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "about:mozilla", triggeringPrincipal_base64 }, + { url: "about:config", triggeringPrincipal_base64 }, + ], + index: 1, + userTypedValue: "example.org", + userTypedClear: 0, + }, + ], + }, + ], + }; + + waitForBrowserState(state, function() { + let browser = gBrowser.selectedBrowser; + is( + browser.currentURI.spec, + "about:mozilla", + "browser.currentURI set to current entry in SH" + ); + is( + browser.userTypedValue, + "example.org", + "userTypedValue was correctly restored" + ); + ok( + !browser.didStartLoadSinceLastUserTyping(), + "We still know that no load is ongoing" + ); + is( + gURLBar.value, + "example.org", + "Address bar's value correctly restored to userTypedValue" + ); + runNextTest(); + }); + } + + // This test simulates lots of tabs opening at once and then quitting/crashing. + function test_getBrowserState_lotsOfTabsOpening() { + gBrowser.stop(); + + let uris = []; + for (let i = 0; i < 25; i++) { + uris.push("http://example.com/" + i); + } + + // We're waiting for the first location change, which should indicate + // one of the tabs has loaded and the others haven't. So one should + // be in a non-userTypedValue case, while others should still have + // userTypedValue and userTypedClear set. + gBrowser.addTabsProgressListener({ + onLocationChange(aBrowser) { + if (uris.indexOf(aBrowser.currentURI.spec) > -1) { + gBrowser.removeTabsProgressListener(this); + firstLocationChange(); + } + }, + }); + + function firstLocationChange() { + let state = JSON.parse(ss.getBrowserState()); + let hasUTV = state.windows[0].tabs.some(function(aTab) { + return ( + aTab.userTypedValue && aTab.userTypedClear && !aTab.entries.length + ); + }); + + ok( + hasUTV, + "At least one tab has a userTypedValue with userTypedClear with no loaded URL" + ); + + BrowserTestUtils.waitForMessage( + gBrowser.selectedBrowser.messageManager, + "SessionStore:update" + ).then(firstLoad); + } + + function firstLoad() { + let state = JSON.parse(ss.getTabState(gBrowser.selectedTab)); + let hasSH = !("userTypedValue" in state) && state.entries[0].url; + ok(hasSH, "The selected tab has its entry in SH"); + + runNextTest(); + } + + gBrowser.loadTabs(uris, { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + } + + // This simulates setting a userTypedValue and ensures that just typing in the + // URL bar doesn't set userTypedClear as well. + function test_getBrowserState_userTypedValue() { + let state = { + windows: [ + { + tabs: [{ entries: [] }], + }, + ], + }; + + waitForBrowserState(state, function() { + let browser = gBrowser.selectedBrowser; + // Make sure this tab isn't loading and state is clear before we test. + is(browser.userTypedValue, null, "userTypedValue is empty to start"); + ok( + !browser.didStartLoadSinceLastUserTyping(), + "Initially, no load should be ongoing" + ); + + let inputText = "example.org"; + gURLBar.focus(); + gURLBar.value = inputText.slice(0, -1); + EventUtils.sendString(inputText.slice(-1)); + + executeSoon(function() { + is( + browser.userTypedValue, + "example.org", + "userTypedValue was set when changing URLBar value" + ); + ok( + !browser.didStartLoadSinceLastUserTyping(), + "No load started since changing URLBar value" + ); + + // Now make sure ss gets these values too + let newState = JSON.parse(ss.getBrowserState()); + is( + newState.windows[0].tabs[0].userTypedValue, + "example.org", + "sessionstore got correct userTypedValue" + ); + is( + newState.windows[0].tabs[0].userTypedClear, + 0, + "sessionstore got correct userTypedClear" + ); + runNextTest(); + }); + }); + } + + // test_getBrowserState_lotsOfTabsOpening tested userTypedClear in a few cases, + // but not necessarily any that had legitimate URIs in the state of loading + // (eg, "http://example.com"), so this test will cover that case. + function test_userTypedClearLoadURI() { + let state = { + windows: [ + { + tabs: [ + { + entries: [], + userTypedValue: "http://example.com", + userTypedClear: 2, + }, + ], + }, + ], + }; + + waitForBrowserState(state, function() { + let browser = gBrowser.selectedBrowser; + is( + browser.currentURI.spec, + "http://example.com/", + "userTypedClear=2 caused userTypedValue to be loaded" + ); + is( + browser.userTypedValue, + null, + "userTypedValue was null after loading a URI" + ); + ok( + !browser.didStartLoadSinceLastUserTyping(), + "We should have reset the load state when the tab loaded" + ); + is( + gURLBar.value, + BrowserUIUtils.trimURL("http://example.com/"), + "Address bar's value set after loading URI" + ); + runNextTest(); + }); + } + + let tests = [ + test_newTabFocused, + test_newTabNotFocused, + test_existingSHEnd_noClear, + test_existingSHMiddle_noClear, + test_getBrowserState_lotsOfTabsOpening, + test_getBrowserState_userTypedValue, + test_userTypedClearLoadURI, + ]; + let originalState = JSON.parse(ss.getBrowserState()); + let state = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + }; + function runNextTest() { + if (tests.length) { + waitForBrowserState(state, function() { + gBrowser.selectedBrowser.userTypedValue = null; + gURLBar.setURI(); + tests.shift()(); + }); + } else { + waitForBrowserState(originalState, function() { + gBrowser.selectedBrowser.userTypedValue = null; + gURLBar.setURI(); + finish(); + }); + } + } + + // Run the tests! + runNextTest(); +} diff --git a/browser/components/sessionstore/test/browser_524745.js b/browser/components/sessionstore/test/browser_524745.js new file mode 100644 index 0000000000..3ef9beeb63 --- /dev/null +++ b/browser/components/sessionstore/test/browser_524745.js @@ -0,0 +1,50 @@ +/* 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/. */ + +function test() { + /** Test for Bug 524745 **/ + + let uniqKey = "bug524745"; + let uniqVal = Date.now().toString(); + + waitForExplicitFinish(); + + whenNewWindowLoaded({ private: false }, function(window_B) { + waitForFocus(function() { + // Add identifying information to window_B + ss.setCustomWindowValue(window_B, uniqKey, uniqVal); + let state = JSON.parse(ss.getBrowserState()); + let selectedWindow = state.windows[state.selectedWindow - 1]; + is( + selectedWindow.extData && selectedWindow.extData[uniqKey], + uniqVal, + "selectedWindow is window_B" + ); + + // Now minimize window_B. The selected window shouldn't have the secret data + window_B.minimize(); + waitForFocus(function() { + state = JSON.parse(ss.getBrowserState()); + selectedWindow = state.windows[state.selectedWindow - 1]; + ok( + !selectedWindow.extData || !selectedWindow.extData[uniqKey], + "selectedWindow is not window_B after minimizing it" + ); + + // Now minimize the last open window (assumes no other tests left windows open) + window.minimize(); + state = JSON.parse(ss.getBrowserState()); + is( + state.selectedWindow, + 0, + "selectedWindow should be 0 when all windows are minimized" + ); + + // Cleanup + window.restore(); + BrowserTestUtils.closeWindow(window_B).then(finish); + }); + }, window_B); + }); +} diff --git a/browser/components/sessionstore/test/browser_526613.js b/browser/components/sessionstore/test/browser_526613.js new file mode 100644 index 0000000000..ba3f03ef32 --- /dev/null +++ b/browser/components/sessionstore/test/browser_526613.js @@ -0,0 +1,86 @@ +/* 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/. */ + +function test() { + /** Test for Bug 526613 **/ + + // test setup + waitForExplicitFinish(); + + function browserWindowsCount(expected) { + let count = 0; + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (!win.closed) { + ++count; + } + } + is( + count, + expected, + "number of open browser windows according to nsIWindowMediator" + ); + let state = ss.getBrowserState(); + info(state); + is( + JSON.parse(state).windows.length, + expected, + "number of open browser windows according to getBrowserState" + ); + } + + browserWindowsCount(1); + + // backup old state + let oldState = ss.getBrowserState(); + // create a new state for testing + let testState = { + windows: [ + { tabs: [{ entries: [{ url: "http://example.com/" }] }], selected: 1 }, + { tabs: [{ entries: [{ url: "about:mozilla" }] }], selected: 1 }, + ], + // make sure the first window is focused, otherwise when restoring the + // old state, the first window is closed and the test harness gets unloaded + selectedWindow: 1, + }; + + let pass = 1; + function observer(aSubject, aTopic, aData) { + is( + aTopic, + "sessionstore-browser-state-restored", + "The sessionstore-browser-state-restored notification was observed" + ); + + if (pass++ == 1) { + browserWindowsCount(2); + + // let the first window be focused (see above) + function pollMostRecentWindow() { + if (Services.wm.getMostRecentWindow("navigator:browser") == window) { + ss.setBrowserState(oldState); + } else { + info("waiting for the current window to become active"); + setTimeout(pollMostRecentWindow, 0); + window.focus(); // XXX Why is this needed? + } + } + pollMostRecentWindow(); + } else { + browserWindowsCount(1); + ok( + !window.closed, + "Restoring the old state should have left this window open" + ); + Services.obs.removeObserver( + observer, + "sessionstore-browser-state-restored" + ); + finish(); + } + } + Services.obs.addObserver(observer, "sessionstore-browser-state-restored"); + + // set browser to test state + ss.setBrowserState(JSON.stringify(testState)); +} diff --git a/browser/components/sessionstore/test/browser_528776.js b/browser/components/sessionstore/test/browser_528776.js new file mode 100644 index 0000000000..b488d063a0 --- /dev/null +++ b/browser/components/sessionstore/test/browser_528776.js @@ -0,0 +1,27 @@ +function browserWindowsCount(expected) { + var count = 0; + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (!win.closed) { + ++count; + } + } + is( + count, + expected, + "number of open browser windows according to nsIWindowMediator" + ); + is( + JSON.parse(ss.getBrowserState()).windows.length, + expected, + "number of open browser windows according to getBrowserState" + ); +} + +add_task(async function() { + browserWindowsCount(1); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + browserWindowsCount(2); + await BrowserTestUtils.closeWindow(win); + browserWindowsCount(1); +}); diff --git a/browser/components/sessionstore/test/browser_579868.js b/browser/components/sessionstore/test/browser_579868.js new file mode 100644 index 0000000000..fd241ee964 --- /dev/null +++ b/browser/components/sessionstore/test/browser_579868.js @@ -0,0 +1,31 @@ +/* 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/. */ + +function test() { + waitForExplicitFinish(); + + let tab1 = BrowserTestUtils.addTab(gBrowser, "about:rights"); + let tab2 = BrowserTestUtils.addTab(gBrowser, "about:mozilla"); + + promiseBrowserLoaded(tab1.linkedBrowser).then(() => { + // Tell the session storer that the tab is pinned + let newTabState = + '{"entries":[{"url":"about:rights"}],"pinned":true,"userTypedValue":"Hello World!"}'; + ss.setTabState(tab1, newTabState); + + // Undo pinning + gBrowser.unpinTab(tab1); + + // Close and restore tab + gBrowser.removeTab(tab1); + let savedState = ss.getClosedTabData(window)[0].state; + isnot(savedState.pinned, true, "Pinned should not be true"); + tab1 = ss.undoCloseTab(window, 0); + + isnot(tab1.pinned, true, "Should not be pinned"); + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + finish(); + }); +} diff --git a/browser/components/sessionstore/test/browser_579879.js b/browser/components/sessionstore/test/browser_579879.js new file mode 100644 index 0000000000..4bad262618 --- /dev/null +++ b/browser/components/sessionstore/test/browser_579879.js @@ -0,0 +1,31 @@ +"use strict"; + +add_task(async function() { + let tab1 = BrowserTestUtils.addTab( + gBrowser, + "data:text/plain;charset=utf-8,foo" + ); + gBrowser.pinTab(tab1); + + await promiseBrowserLoaded(tab1.linkedBrowser); + let tab2 = BrowserTestUtils.addTab(gBrowser); + gBrowser.pinTab(tab2); + + is( + Array.prototype.indexOf.call(gBrowser.tabs, tab1), + 0, + "pinned tab 1 is at the first position" + ); + await promiseRemoveTabAndSessionState(tab1); + + tab1 = undoCloseTab(); + ok(tab1.pinned, "pinned tab 1 has been restored as a pinned tab"); + is( + Array.prototype.indexOf.call(gBrowser.tabs, tab1), + 0, + "pinned tab 1 has been restored to the first position" + ); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/sessionstore/test/browser_580512.js b/browser/components/sessionstore/test/browser_580512.js new file mode 100644 index 0000000000..86ea92b74b --- /dev/null +++ b/browser/components/sessionstore/test/browser_580512.js @@ -0,0 +1,117 @@ +/* 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/. */ + +const URIS_PINNED = ["about:license", "about:about"]; +const URIS_NORMAL_A = ["about:mozilla"]; +const URIS_NORMAL_B = ["about:buildconfig"]; + +function test() { + waitForExplicitFinish(); + + isnot( + Services.prefs.getIntPref("browser.startup.page"), + 3, + "pref to save session must not be set for this test" + ); + ok( + !Services.prefs.getBoolPref("browser.sessionstore.resume_session_once"), + "pref to save session once must not be set for this test" + ); + + document.documentElement.setAttribute( + "windowtype", + "navigator:browsertestdummy" + ); + + openWinWithCb(closeFirstWin, URIS_PINNED.concat(URIS_NORMAL_A)); +} + +function closeFirstWin(win) { + win.gBrowser.pinTab(win.gBrowser.tabs[0]); + win.gBrowser.pinTab(win.gBrowser.tabs[1]); + + let winClosed = BrowserTestUtils.windowClosed(win); + // We need to call BrowserTryToCloseWindow in order to trigger + // the machinery that chooses whether or not to save the session + // for the last window. + win.BrowserTryToCloseWindow(); + ok(win.closed, "window closed"); + + winClosed.then(() => { + openWinWithCb( + checkSecondWin, + URIS_NORMAL_B, + URIS_PINNED.concat(URIS_NORMAL_B) + ); + }); +} + +function checkSecondWin(win) { + is( + win.gBrowser.browsers[0].currentURI.spec, + URIS_PINNED[0], + "first pinned tab restored" + ); + is( + win.gBrowser.browsers[1].currentURI.spec, + URIS_PINNED[1], + "second pinned tab restored" + ); + ok(win.gBrowser.tabs[0].pinned, "first pinned tab is still pinned"); + ok(win.gBrowser.tabs[1].pinned, "second pinned tab is still pinned"); + + BrowserTestUtils.closeWindow(win).then(() => { + // cleanup + document.documentElement.setAttribute("windowtype", "navigator:browser"); + finish(); + }); +} + +function openWinWithCb(cb, argURIs, expectedURIs) { + if (!expectedURIs) { + expectedURIs = argURIs; + } + + var win = openDialog( + AppConstants.BROWSER_CHROME_URL, + "_blank", + "chrome,all,dialog=no", + argURIs.join("|") + ); + + win.addEventListener( + "load", + function() { + info("the window loaded"); + + var expectedLoads = expectedURIs.length; + + win.gBrowser.addTabsProgressListener({ + onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) { + if ( + aRequest && + aStateFlags & Ci.nsIWebProgressListener.STATE_STOP && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK && + expectedURIs.indexOf( + aRequest.QueryInterface(Ci.nsIChannel).originalURI.spec + ) > -1 && + --expectedLoads <= 0 + ) { + win.gBrowser.removeTabsProgressListener(this); + info("all tabs loaded"); + is( + win.gBrowser.tabs.length, + expectedURIs.length, + "didn't load any unexpected tabs" + ); + executeSoon(function() { + cb(win); + }); + } + }, + }); + }, + { once: true } + ); +} diff --git a/browser/components/sessionstore/test/browser_581937.js b/browser/components/sessionstore/test/browser_581937.js new file mode 100644 index 0000000000..b0f4d037d6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_581937.js @@ -0,0 +1,22 @@ +// Tests that an about:blank tab with no history will not be saved into +// session store and thus, it will not show up in Recently Closed Tabs. + +"use strict"; + +add_task(async function() { + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + await promiseBrowserLoaded(tab.linkedBrowser); + + is( + tab.linkedBrowser.currentURI.spec, + "about:blank", + "we will be removing an about:blank tab" + ); + + let r = `rand-${Math.random()}`; + ss.setCustomTabValue(tab, "foobar", r); + + await promiseRemoveTabAndSessionState(tab); + let closedTabData = ss.getClosedTabData(window); + ok(!closedTabData.includes(r), "tab not stored in _closedTabs"); +}); diff --git a/browser/components/sessionstore/test/browser_586068-apptabs.js b/browser/components/sessionstore/test/browser_586068-apptabs.js new file mode 100644 index 0000000000..0f0923e4c8 --- /dev/null +++ b/browser/components/sessionstore/test/browser_586068-apptabs.js @@ -0,0 +1,109 @@ +/* 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/. */ + +requestLongerTimeout(2); + +const PREF_RESTORE_ON_DEMAND = "browser.sessionstore.restore_on_demand"; + +add_task(async function test() { + Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, true); + registerCleanupFunction(function() { + Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND); + }); + + let state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org/#1", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + pinned: true, + }, + { + entries: [ + { url: "http://example.org/#2", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + pinned: true, + }, + { + entries: [ + { url: "http://example.org/#3", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + pinned: true, + }, + { + entries: [ + { url: "http://example.org/#4", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#5", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#6", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#7", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 5, + }, + ], + }; + + let loadCount = 0; + let promiseRestoringTabs = new Promise(resolve => { + gProgressListener.setCallback(function( + aBrowser, + aNeedRestore, + aRestoring, + aRestored + ) { + loadCount++; + + // We'll make sure that the loads we get come from pinned tabs or the + // the selected tab. + + // get the tab + let tab; + for (let i = 0; i < window.gBrowser.tabs.length; i++) { + if (!tab && window.gBrowser.tabs[i].linkedBrowser == aBrowser) { + tab = window.gBrowser.tabs[i]; + } + } + + ok(tab.pinned || tab.selected, "load came from pinned or selected tab"); + + // We should get 4 loads: 3 app tabs + 1 normal selected tab + if (loadCount < 4) { + return; + } + + gProgressListener.unsetCallback(); + resolve(); + }); + }); + + let backupState = ss.getBrowserState(); + ss.setBrowserState(JSON.stringify(state)); + await promiseRestoringTabs; + + // Cleanup. + await promiseBrowserState(backupState); +}); diff --git a/browser/components/sessionstore/test/browser_586068-apptabs_ondemand.js b/browser/components/sessionstore/test/browser_586068-apptabs_ondemand.js new file mode 100644 index 0000000000..565805cca6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_586068-apptabs_ondemand.js @@ -0,0 +1,105 @@ +/* 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/. */ + +const PREF_RESTORE_ON_DEMAND = "browser.sessionstore.restore_on_demand"; +const PREF_RESTORE_PINNED_TABS_ON_DEMAND = + "browser.sessionstore.restore_pinned_tabs_on_demand"; + +add_task(async function test() { + Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, true); + Services.prefs.setBoolPref(PREF_RESTORE_PINNED_TABS_ON_DEMAND, true); + + registerCleanupFunction(function() { + Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND); + Services.prefs.clearUserPref(PREF_RESTORE_PINNED_TABS_ON_DEMAND); + }); + + let state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org/#1", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + pinned: true, + }, + { + entries: [ + { url: "http://example.org/#2", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + pinned: true, + }, + { + entries: [ + { url: "http://example.org/#3", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + pinned: true, + }, + { + entries: [ + { url: "http://example.org/#4", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#5", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#6", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#7", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 5, + }, + ], + }; + + let promiseRestoringTabs = new Promise(resolve => { + gProgressListener.setCallback(function( + aBrowser, + aNeedRestore, + aRestoring, + aRestored + ) { + // get the tab + let tab; + for (let i = 0; i < window.gBrowser.tabs.length; i++) { + if (!tab && window.gBrowser.tabs[i].linkedBrowser == aBrowser) { + tab = window.gBrowser.tabs[i]; + } + } + + // Check that the load only comes from the selected tab. + ok(tab.selected, "load came from selected tab"); + is(aNeedRestore, 6, "six tabs left to restore"); + is(aRestoring, 1, "one tab is restoring"); + is(aRestored, 0, "no tabs have been restored, yet"); + + gProgressListener.unsetCallback(); + resolve(); + }); + }); + + let backupState = ss.getBrowserState(); + ss.setBrowserState(JSON.stringify(state)); + await promiseRestoringTabs; + + // Cleanup. + await promiseBrowserState(backupState); +}); diff --git a/browser/components/sessionstore/test/browser_586068-browser_state_interrupted.js b/browser/components/sessionstore/test/browser_586068-browser_state_interrupted.js new file mode 100644 index 0000000000..4ee9ea0797 --- /dev/null +++ b/browser/components/sessionstore/test/browser_586068-browser_state_interrupted.js @@ -0,0 +1,212 @@ +/* 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/. */ + +const PREF_RESTORE_ON_DEMAND = "browser.sessionstore.restore_on_demand"; + +requestLongerTimeout(2); + +add_task(async function test() { + Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, false); + registerCleanupFunction(function() { + Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND); + }); + + // The first state will be loaded using setBrowserState, followed by the 2nd + // state also being loaded using setBrowserState, interrupting the first restore. + let state1 = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org#1", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#2", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#3", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#4", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 1, + }, + { + tabs: [ + { + entries: [ + { url: "http://example.com#1", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#2", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#3", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#4", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 3, + }, + ], + }; + let state2 = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org#5", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#6", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#7", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#8", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 3, + }, + { + tabs: [ + { + entries: [ + { url: "http://example.com#5", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#6", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#7", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#8", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 1, + }, + ], + }; + + // interruptedAfter will be set after the selected tab from each window have loaded. + let interruptedAfter = 0; + let loadedWindow1 = false; + let loadedWindow2 = false; + let numTabs = state2.windows[0].tabs.length + state2.windows[1].tabs.length; + + let loadCount = 0; + let promiseRestoringTabs = new Promise(resolve => { + gProgressListener.setCallback(function( + aBrowser, + aNeedRestore, + aRestoring, + aRestored + ) { + loadCount++; + + if ( + aBrowser.currentURI.spec == state1.windows[0].tabs[2].entries[0].url + ) { + loadedWindow1 = true; + } + if ( + aBrowser.currentURI.spec == state1.windows[1].tabs[0].entries[0].url + ) { + loadedWindow2 = true; + } + + if (!interruptedAfter && loadedWindow1 && loadedWindow2) { + interruptedAfter = loadCount; + ss.setBrowserState(JSON.stringify(state2)); + return; + } + + if (loadCount < numTabs + interruptedAfter) { + return; + } + + // We don't actually care about load order in this test, just that they all + // do load. + is(loadCount, numTabs + interruptedAfter, "all tabs were restored"); + is(aNeedRestore, 0, "there are no tabs left needing restore"); + + // Remove the progress listener. + gProgressListener.unsetCallback(); + resolve(); + }); + }); + + // We also want to catch the extra windows (there should be 2), so we need to observe domwindowopened + Services.ww.registerNotification(function observer(aSubject, aTopic, aData) { + if (aTopic == "domwindowopened") { + let win = aSubject; + win.addEventListener( + "load", + function() { + Services.ww.unregisterNotification(observer); + win.gBrowser.addTabsProgressListener(gProgressListener); + }, + { once: true } + ); + } + }); + + let backupState = ss.getBrowserState(); + ss.setBrowserState(JSON.stringify(state1)); + await promiseRestoringTabs; + + // Cleanup. + await promiseAllButPrimaryWindowClosed(); + await promiseBrowserState(backupState); +}); diff --git a/browser/components/sessionstore/test/browser_586068-cascade.js b/browser/components/sessionstore/test/browser_586068-cascade.js new file mode 100644 index 0000000000..78492fd2d6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_586068-cascade.js @@ -0,0 +1,107 @@ +/* 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/. */ + +const PREF_RESTORE_ON_DEMAND = "browser.sessionstore.restore_on_demand"; + +add_task(async function test() { + Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, false); + registerCleanupFunction(function() { + Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND); + }); + + let state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.com", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + }, + ], + }; + + let expectedCounts = [ + [3, 3, 0], + [2, 3, 1], + [1, 3, 2], + [0, 3, 3], + [0, 2, 4], + [0, 1, 5], + ]; + + let loadCount = 0; + let promiseRestoringTabs = new Promise(resolve => { + gProgressListener.setCallback(function( + aBrowser, + aNeedRestore, + aRestoring, + aRestored + ) { + loadCount++; + let expected = expectedCounts[loadCount - 1]; + + is( + aNeedRestore, + expected[0], + "load " + loadCount + " - # tabs that need to be restored" + ); + is( + aRestoring, + expected[1], + "load " + loadCount + " - # tabs that are restoring" + ); + is( + aRestored, + expected[2], + "load " + loadCount + " - # tabs that has been restored" + ); + + if (loadCount == state.windows[0].tabs.length) { + gProgressListener.unsetCallback(); + resolve(); + } + }); + }); + + let backupState = ss.getBrowserState(); + ss.setBrowserState(JSON.stringify(state)); + await promiseRestoringTabs; + + // Cleanup. + await promiseBrowserState(backupState); +}); diff --git a/browser/components/sessionstore/test/browser_586068-multi_window.js b/browser/components/sessionstore/test/browser_586068-multi_window.js new file mode 100644 index 0000000000..0acf4bc788 --- /dev/null +++ b/browser/components/sessionstore/test/browser_586068-multi_window.js @@ -0,0 +1,115 @@ +/* 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/. */ + +const PREF_RESTORE_ON_DEMAND = "browser.sessionstore.restore_on_demand"; + +add_task(async function test() { + Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, false); + registerCleanupFunction(function() { + Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND); + }); + + // The first window will be put into the already open window and the second + // window will be opened with _openWindowWithState, which is the source of the problem. + let state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org#0", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 1, + }, + { + tabs: [ + { + entries: [ + { url: "http://example.com#1", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#2", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#3", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#4", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#5", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#6", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 4, + }, + ], + }; + let numTabs = state.windows[0].tabs.length + state.windows[1].tabs.length; + + let loadCount = 0; + let promiseRestoringTabs = new Promise(resolve => { + gProgressListener.setCallback(function( + aBrowser, + aNeedRestore, + aRestoring, + aRestored + ) { + if (++loadCount == numTabs) { + // We don't actually care about load order in this test, just that they all + // do load. + is(loadCount, numTabs, "all tabs were restored"); + is(aNeedRestore, 0, "there are no tabs left needing restore"); + + gProgressListener.unsetCallback(); + resolve(); + } + }); + }); + + // We also want to catch the 2nd window, so we need to observe domwindowopened + Services.ww.registerNotification(function observer(aSubject, aTopic, aData) { + if (aTopic == "domwindowopened") { + let win = aSubject; + win.addEventListener( + "load", + function() { + Services.ww.unregisterNotification(observer); + win.gBrowser.addTabsProgressListener(gProgressListener); + }, + { once: true } + ); + } + }); + + let backupState = ss.getBrowserState(); + ss.setBrowserState(JSON.stringify(state)); + await promiseRestoringTabs; + + // Cleanup. + await promiseAllButPrimaryWindowClosed(); + await promiseBrowserState(backupState); +}); diff --git a/browser/components/sessionstore/test/browser_586068-reload.js b/browser/components/sessionstore/test/browser_586068-reload.js new file mode 100644 index 0000000000..cd616dabde --- /dev/null +++ b/browser/components/sessionstore/test/browser_586068-reload.js @@ -0,0 +1,114 @@ +/* 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/. */ + +const PREF_RESTORE_ON_DEMAND = "browser.sessionstore.restore_on_demand"; + +add_task(async function test() { + Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, true); + registerCleanupFunction(function() { + Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND); + }); + + let state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org/#1", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#2", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#3", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#4", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#5", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#6", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#7", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#8", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org/#9", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 1, + }, + ], + }; + + let loadCount = 0; + let promiseRestoringTabs = new Promise(resolve => { + gBrowser.tabContainer.addEventListener("SSTabRestored", function onRestored( + event + ) { + let tab = event.target; + let browser = tab.linkedBrowser; + let tabData = state.windows[0].tabs[loadCount++]; + + // double check that this tab was the right one + is( + browser.currentURI.spec, + tabData.entries[0].url, + "load " + loadCount + " - browser loaded correct url" + ); + is( + ss.getCustomTabValue(tab, "uniq"), + tabData.extData.uniq, + "load " + loadCount + " - correct tab was restored" + ); + + if (loadCount == state.windows[0].tabs.length) { + gBrowser.tabContainer.removeEventListener("SSTabRestored", onRestored); + resolve(); + } else { + // reload the next tab + gBrowser.browsers[loadCount].reload(); + } + }); + }); + + let backupState = ss.getBrowserState(); + ss.setBrowserState(JSON.stringify(state)); + await promiseRestoringTabs; + + // Cleanup. + await promiseBrowserState(backupState); +}); diff --git a/browser/components/sessionstore/test/browser_586068-select.js b/browser/components/sessionstore/test/browser_586068-select.js new file mode 100644 index 0000000000..d7de090b7f --- /dev/null +++ b/browser/components/sessionstore/test/browser_586068-select.js @@ -0,0 +1,128 @@ +/* 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/. */ + +const PREF_RESTORE_ON_DEMAND = "browser.sessionstore.restore_on_demand"; + +add_task(async function test() { + Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, true); + registerCleanupFunction(function() { + Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND); + }); + + let state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 1, + }, + ], + }; + + let expectedCounts = [ + [5, 1, 0], + [4, 1, 1], + [3, 1, 2], + [2, 1, 3], + [1, 1, 4], + [0, 1, 5], + ]; + let tabOrder = [0, 5, 1, 4, 3, 2]; + + let loadCount = 0; + let promiseRestoringTabs = new Promise(resolve => { + gProgressListener.setCallback(function( + aBrowser, + aNeedRestore, + aRestoring, + aRestored + ) { + loadCount++; + let expected = expectedCounts[loadCount - 1]; + + is( + aNeedRestore, + expected[0], + "load " + loadCount + " - # tabs that need to be restored" + ); + is( + aRestoring, + expected[1], + "load " + loadCount + " - # tabs that are restoring" + ); + is( + aRestored, + expected[2], + "load " + loadCount + " - # tabs that has been restored" + ); + + if (loadCount < state.windows[0].tabs.length) { + // double check that this tab was the right one + let expectedData = + state.windows[0].tabs[tabOrder[loadCount - 1]].extData.uniq; + let tab; + for (let i = 0; i < window.gBrowser.tabs.length; i++) { + if (!tab && window.gBrowser.tabs[i].linkedBrowser == aBrowser) { + tab = window.gBrowser.tabs[i]; + } + } + + is( + ss.getCustomTabValue(tab, "uniq"), + expectedData, + "load " + loadCount + " - correct tab was restored" + ); + + // select the next tab + window.gBrowser.selectTabAtIndex(tabOrder[loadCount]); + } else { + gProgressListener.unsetCallback(); + resolve(); + } + }); + }); + + let backupState = ss.getBrowserState(); + ss.setBrowserState(JSON.stringify(state)); + await promiseRestoringTabs; + + // Cleanup. + await promiseBrowserState(backupState); +}); diff --git a/browser/components/sessionstore/test/browser_586068-window_state.js b/browser/components/sessionstore/test/browser_586068-window_state.js new file mode 100644 index 0000000000..56ffc576f8 --- /dev/null +++ b/browser/components/sessionstore/test/browser_586068-window_state.js @@ -0,0 +1,120 @@ +/* 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/. */ + +const PREF_RESTORE_ON_DEMAND = "browser.sessionstore.restore_on_demand"; + +add_task(async function test() { + Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, false); + registerCleanupFunction(function() { + Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND); + }); + + // We'll use 2 states so that we can make sure calling setWindowState doesn't + // wipe out currently restoring data. + let state1 = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.com#1", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#2", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#3", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#4", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#5", triggeringPrincipal_base64 }, + ], + }, + ], + }, + ], + }; + let state2 = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org#1", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org#2", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org#3", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org#4", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org#5", triggeringPrincipal_base64 }, + ], + }, + ], + }, + ], + }; + let numTabs = state1.windows[0].tabs.length + state2.windows[0].tabs.length; + + let loadCount = 0; + let promiseRestoringTabs = new Promise(resolve => { + gProgressListener.setCallback(function( + aBrowser, + aNeedRestore, + aRestoring, + aRestored + ) { + // When loadCount == 2, we'll also restore state2 into the window + if (++loadCount == 2) { + ss.setWindowState(window, JSON.stringify(state2), false); + } + + if (loadCount < numTabs) { + return; + } + + // We don't actually care about load order in this test, just that they all + // do load. + is( + loadCount, + numTabs, + "test_setWindowStateNoOverwrite: all tabs were restored" + ); + is(aNeedRestore, 0, "there are no tabs left needing restore"); + + gProgressListener.unsetCallback(); + resolve(); + }); + }); + + let backupState = ss.getBrowserState(); + ss.setWindowState(window, JSON.stringify(state1), true); + await promiseRestoringTabs; + + // Cleanup. + await promiseBrowserState(backupState); +}); diff --git a/browser/components/sessionstore/test/browser_586068-window_state_override.js b/browser/components/sessionstore/test/browser_586068-window_state_override.js new file mode 100644 index 0000000000..49838fb4ce --- /dev/null +++ b/browser/components/sessionstore/test/browser_586068-window_state_override.js @@ -0,0 +1,118 @@ +/* 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/. */ + +const PREF_RESTORE_ON_DEMAND = "browser.sessionstore.restore_on_demand"; + +add_task(async function test() { + Services.prefs.setBoolPref(PREF_RESTORE_ON_DEMAND, false); + registerCleanupFunction(function() { + Services.prefs.clearUserPref(PREF_RESTORE_ON_DEMAND); + }); + + // We'll use 2 states so that we can make sure calling setWindowState doesn't + // wipe out currently restoring data. + let state1 = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.com#1", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#2", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#3", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#4", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#5", triggeringPrincipal_base64 }, + ], + }, + ], + }, + ], + }; + let state2 = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org#1", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org#2", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org#3", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org#4", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org#5", triggeringPrincipal_base64 }, + ], + }, + ], + }, + ], + }; + let numTabs = 2 + state2.windows[0].tabs.length; + + let loadCount = 0; + let promiseRestoringTabs = new Promise(resolve => { + gProgressListener.setCallback(function( + aBrowser, + aNeedRestore, + aRestoring, + aRestored + ) { + // When loadCount == 2, we'll also restore state2 into the window + if (++loadCount == 2) { + executeSoon(() => + ss.setWindowState(window, JSON.stringify(state2), true) + ); + } + + if (loadCount < numTabs) { + return; + } + + // We don't actually care about load order in this test, just that they all + // do load. + is(loadCount, numTabs, "all tabs were restored"); + is(aNeedRestore, 0, "there are no tabs left needing restore"); + + gProgressListener.unsetCallback(); + resolve(); + }); + }); + + let backupState = ss.getBrowserState(); + ss.setWindowState(window, JSON.stringify(state1), true); + await promiseRestoringTabs; + + // Cleanup. + await promiseBrowserState(backupState); +}); diff --git a/browser/components/sessionstore/test/browser_586147.js b/browser/components/sessionstore/test/browser_586147.js new file mode 100644 index 0000000000..3797c66628 --- /dev/null +++ b/browser/components/sessionstore/test/browser_586147.js @@ -0,0 +1,52 @@ +/* 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/. */ + +function observeOneRestore(callback) { + let topic = "sessionstore-browser-state-restored"; + Services.obs.addObserver(function onRestore() { + Services.obs.removeObserver(onRestore, topic); + callback(); + }, topic); +} + +function test() { + waitForExplicitFinish(); + + // There should be one tab when we start the test + let [origTab] = gBrowser.visibleTabs; + let hiddenTab = BrowserTestUtils.addTab(gBrowser); + + is(gBrowser.visibleTabs.length, 2, "should have 2 tabs before hiding"); + gBrowser.showOnlyTheseTabs([origTab]); + is(gBrowser.visibleTabs.length, 1, "only 1 after hiding"); + ok(hiddenTab.hidden, "sanity check that it's hidden"); + + BrowserTestUtils.addTab(gBrowser); + let state = ss.getBrowserState(); + let stateObj = JSON.parse(state); + let tabs = stateObj.windows[0].tabs; + is(tabs.length, 3, "just checking that browser state is correct"); + ok(!tabs[0].hidden, "first tab is visible"); + ok(tabs[1].hidden, "second is hidden"); + ok(!tabs[2].hidden, "third is visible"); + + // Make the third tab hidden and then restore the modified state object + tabs[2].hidden = true; + + observeOneRestore(function() { + is(gBrowser.visibleTabs.length, 1, "only restored 1 visible tab"); + let restoredTabs = gBrowser.tabs; + + ok(!restoredTabs[0].hidden, "first is still visible"); + ok(restoredTabs[1].hidden, "second tab is still hidden"); + ok(restoredTabs[2].hidden, "third tab is now hidden"); + + // Restore the original state and clean up now that we're done + gBrowser.removeTab(gBrowser.tabs[1]); + gBrowser.removeTab(gBrowser.tabs[1]); + + finish(); + }); + ss.setBrowserState(JSON.stringify(stateObj)); +} diff --git a/browser/components/sessionstore/test/browser_588426.js b/browser/components/sessionstore/test/browser_588426.js new file mode 100644 index 0000000000..fe7a2d9211 --- /dev/null +++ b/browser/components/sessionstore/test/browser_588426.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() { + let state = { + windows: [ + { + tabs: [ + { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + hidden: true, + }, + { + entries: [{ url: "about:rights", triggeringPrincipal_base64 }], + hidden: true, + }, + ], + }, + ], + }; + + waitForExplicitFinish(); + + newWindowWithState(state, function(win) { + registerCleanupFunction(() => BrowserTestUtils.closeWindow(win)); + + is(win.gBrowser.tabs.length, 2, "two tabs were restored"); + is(win.gBrowser.visibleTabs.length, 1, "one tab is visible"); + + let tab = win.gBrowser.visibleTabs[0]; + is( + tab.linkedBrowser.currentURI.spec, + "about:mozilla", + "visible tab is about:mozilla" + ); + + finish(); + }); +} + +function newWindowWithState(state, callback) { + let opts = "chrome,all,dialog=no,height=800,width=800"; + let win = window.openDialog(AppConstants.BROWSER_CHROME_URL, "_blank", opts); + + win.addEventListener( + "load", + function() { + executeSoon(function() { + win.addEventListener( + "SSWindowStateReady", + function() { + promiseTabRestored(win.gBrowser.tabs[0]).then(() => callback(win)); + }, + { once: true } + ); + + ss.setWindowState(win, JSON.stringify(state), true); + }); + }, + { once: true } + ); +} diff --git a/browser/components/sessionstore/test/browser_589246.js b/browser/components/sessionstore/test/browser_589246.js new file mode 100644 index 0000000000..db8b68b04e --- /dev/null +++ b/browser/components/sessionstore/test/browser_589246.js @@ -0,0 +1,286 @@ +/* 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/. */ + +// Mirrors WINDOW_ATTRIBUTES IN SessionStore.sys.mjs +const WINDOW_ATTRIBUTES = ["width", "height", "screenX", "screenY", "sizemode"]; + +var stateBackup = ss.getBrowserState(); + +var originalWarnOnClose = Services.prefs.getBoolPref( + "browser.tabs.warnOnClose" +); +var originalStartupPage = Services.prefs.getIntPref("browser.startup.page"); +var originalWindowType = document.documentElement.getAttribute("windowtype"); + +var gotLastWindowClosedTopic = false; +var shouldPinTab = false; +var shouldOpenTabs = false; +var shouldCloseTab = false; +var testNum = 0; +var afterTestCallback; + +// Set state so we know the closed windows content +var testState = { + windows: [{ tabs: [{ entries: [{ url: "http://example.org" }] }] }], + _closedWindows: [], +}; + +// We'll push a set of conditions and callbacks into this array +// Ideally we would also test win/linux under a complete set of conditions, but +// the tests for osx mirror the other set of conditions possible on win/linux. +var tests = []; + +// the third & fourth test share a condition check, keep it DRY +function checkOSX34Generator(num) { + return function(aPreviousState, aCurState) { + // In here, we should have restored the pinned tab, so only the unpinned tab + // should be in aCurState. So let's shape our expectations. + let expectedState = JSON.parse(aPreviousState); + expectedState[0].tabs.shift(); + // size attributes are stripped out in _prepDataForDeferredRestore in SessionStore.sys.mjs. + // This isn't the best approach, but neither is comparing JSON strings + WINDOW_ATTRIBUTES.forEach(attr => delete expectedState[0][attr]); + + is( + aCurState, + JSON.stringify(expectedState), + "test #" + num + ": closedWindowState is as expected" + ); + }; +} +function checkNoWindowsGenerator(num) { + return function(aPreviousState, aCurState) { + is( + aCurState, + "[]", + "test #" + num + ": there should be no closedWindowsLeft" + ); + }; +} + +// The first test has 0 pinned tabs and 1 unpinned tab +tests.push({ + pinned: false, + extra: false, + close: false, + checkWinLin: checkNoWindowsGenerator(1), + checkOSX(aPreviousState, aCurState) { + is(aCurState, aPreviousState, "test #1: closed window state is unchanged"); + }, +}); + +// The second test has 1 pinned tab and 0 unpinned tabs. +tests.push({ + pinned: true, + extra: false, + close: false, + checkWinLin: checkNoWindowsGenerator(2), + checkOSX: checkNoWindowsGenerator(2), +}); + +// The third test has 1 pinned tab and 2 unpinned tabs. +tests.push({ + pinned: true, + extra: true, + close: false, + checkWinLin: checkNoWindowsGenerator(3), + checkOSX: checkOSX34Generator(3), +}); + +// The fourth test has 1 pinned tab, 2 unpinned tabs, and closes one unpinned tab. +tests.push({ + pinned: true, + extra: true, + close: "one", + checkWinLin: checkNoWindowsGenerator(4), + checkOSX: checkOSX34Generator(4), +}); + +// The fifth test has 1 pinned tab, 2 unpinned tabs, and closes both unpinned tabs. +tests.push({ + pinned: true, + extra: true, + close: "both", + checkWinLin: checkNoWindowsGenerator(5), + checkOSX: checkNoWindowsGenerator(5), +}); + +function test() { + /** Test for Bug 589246 - Closed window state getting corrupted when closing + and reopening last browser window without exiting browser **/ + waitForExplicitFinish(); + // windows opening & closing, so extending the timeout + requestLongerTimeout(2); + + // We don't want the quit dialog pref + Services.prefs.setBoolPref("browser.tabs.warnOnClose", false); + // Ensure that we would restore the session (important for Windows) + Services.prefs.setIntPref("browser.startup.page", 3); + + runNextTestOrFinish(); +} + +function runNextTestOrFinish() { + if (tests.length) { + setupForTest(tests.shift()); + } else { + // some state is cleaned up at the end of each test, but not all + ["browser.tabs.warnOnClose", "browser.startup.page"].forEach(function(p) { + if (Services.prefs.prefHasUserValue(p)) { + Services.prefs.clearUserPref(p); + } + }); + + ss.setBrowserState(stateBackup); + executeSoon(finish); + } +} + +function setupForTest(aConditions) { + // reset some checks + gotLastWindowClosedTopic = false; + shouldPinTab = aConditions.pinned; + shouldOpenTabs = aConditions.extra; + shouldCloseTab = aConditions.close; + testNum++; + + // set our test callback + afterTestCallback = /Mac/.test(navigator.platform) + ? aConditions.checkOSX + : aConditions.checkWinLin; + + // Add observers + Services.obs.addObserver( + onLastWindowClosed, + "browser-lastwindow-close-granted" + ); + + // Set the state + Services.obs.addObserver( + onStateRestored, + "sessionstore-browser-state-restored" + ); + ss.setBrowserState(JSON.stringify(testState)); +} + +function onStateRestored(aSubject, aTopic, aData) { + info("test #" + testNum + ": onStateRestored"); + Services.obs.removeObserver( + onStateRestored, + "sessionstore-browser-state-restored" + ); + + // change this window's windowtype so that closing a new window will trigger + // browser-lastwindow-close-granted. + document.documentElement.setAttribute("windowtype", "navigator:testrunner"); + + let newWin = openDialog( + location, + "_blank", + "chrome,all,dialog=no", + "http://example.com" + ); + newWin.addEventListener( + "load", + function(aEvent) { + promiseBrowserLoaded(newWin.gBrowser.selectedBrowser).then(() => { + // pin this tab + if (shouldPinTab) { + newWin.gBrowser.pinTab(newWin.gBrowser.selectedTab); + } + + newWin.addEventListener( + "unload", + function() { + onWindowUnloaded(); + }, + { once: true } + ); + // Open a new tab as well. On Windows/Linux this will be restored when the + // new window is opened below (in onWindowUnloaded). On OS X we'll just + // restore the pinned tabs, leaving the unpinned tab in the closedWindowsData. + if (shouldOpenTabs) { + let newTab = BrowserTestUtils.addTab(newWin.gBrowser, "about:config"); + let newTab2 = BrowserTestUtils.addTab( + newWin.gBrowser, + "about:buildconfig" + ); + + newTab.linkedBrowser.addEventListener( + "load", + function() { + if (shouldCloseTab == "one") { + newWin.gBrowser.removeTab(newTab2); + } else if (shouldCloseTab == "both") { + newWin.gBrowser.removeTab(newTab); + newWin.gBrowser.removeTab(newTab2); + } + newWin.BrowserTryToCloseWindow(); + }, + { capture: true, once: true } + ); + } else { + newWin.BrowserTryToCloseWindow(); + } + }); + }, + { once: true } + ); +} + +// This will be called before the window is actually closed +function onLastWindowClosed(aSubject, aTopic, aData) { + info("test #" + testNum + ": onLastWindowClosed"); + Services.obs.removeObserver( + onLastWindowClosed, + "browser-lastwindow-close-granted" + ); + gotLastWindowClosedTopic = true; +} + +// This is the unload event listener on the new window (from onStateRestored). +// Unload is fired after the window is closed, so sessionstore has already +// updated _closedWindows (which is important). We'll open a new window here +// which should actually trigger the bug. +function onWindowUnloaded() { + info("test #" + testNum + ": onWindowClosed"); + ok( + gotLastWindowClosedTopic, + "test #" + testNum + ": browser-lastwindow-close-granted was notified prior" + ); + + let previousClosedWindowData = ss.getClosedWindowData(); + + // Now we want to open a new window + let newWin = openDialog( + location, + "_blank", + "chrome,all,dialog=no", + "about:mozilla" + ); + newWin.addEventListener( + "load", + function(aEvent) { + newWin.gBrowser.selectedBrowser.addEventListener( + "load", + function() { + // Good enough for checking the state + afterTestCallback(previousClosedWindowData, ss.getClosedWindowData()); + afterTestCleanup(newWin); + }, + { capture: true, once: true } + ); + }, + { once: true } + ); +} + +function afterTestCleanup(aNewWin) { + executeSoon(function() { + BrowserTestUtils.closeWindow(aNewWin).then(() => { + document.documentElement.setAttribute("windowtype", originalWindowType); + runNextTestOrFinish(); + }); + }); +} diff --git a/browser/components/sessionstore/test/browser_590268.js b/browser/components/sessionstore/test/browser_590268.js new file mode 100644 index 0000000000..65f7a94633 --- /dev/null +++ b/browser/components/sessionstore/test/browser_590268.js @@ -0,0 +1,155 @@ +/* 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/. */ + +const NUM_TABS = 12; + +var stateBackup = ss.getBrowserState(); + +function test() { + /** Test for Bug 590268 - Provide access to sessionstore tab data sooner **/ + waitForExplicitFinish(); + requestLongerTimeout(2); + + // wasLoaded will be used to keep track of tabs that have already had SSTabRestoring + // fired for them. + let wasLoaded = {}; + let restoringTabsCount = 0; + let restoredTabsCount = 0; + let uniq2 = {}; + let uniq2Count = 0; + let state = { windows: [{ tabs: [] }] }; + // We're going to put a bunch of tabs into this state + for (let i = 0; i < NUM_TABS; i++) { + let uniq = r(); + let tabData = { + entries: [ + { url: "http://example.com/#" + i, triggeringPrincipal_base64 }, + ], + extData: { uniq, baz: "qux" }, + }; + state.windows[0].tabs.push(tabData); + wasLoaded[uniq] = false; + } + + function onSSTabRestoring(aEvent) { + restoringTabsCount++; + let uniq = ss.getCustomTabValue(aEvent.originalTarget, "uniq"); + wasLoaded[uniq] = true; + + is( + ss.getCustomTabValue(aEvent.originalTarget, "foo"), + "", + "There is no value for 'foo'" + ); + + // On the first SSTabRestoring we're going to run the the real test. + // We'll keep this listener around so we can keep marking tabs as restored. + if (restoringTabsCount == 1) { + onFirstSSTabRestoring(); + } else if (restoringTabsCount == NUM_TABS) { + onLastSSTabRestoring(); + } + } + + function onSSTabRestored(aEvent) { + if (++restoredTabsCount < NUM_TABS) { + return; + } + cleanup(); + } + + function onTabOpen(aEvent) { + // To test bug 614708, we'll just set a value on the tab here. This value + // would previously cause us to not recognize the values in extData until + // much later. So testing "uniq" failed. + ss.setCustomTabValue(aEvent.originalTarget, "foo", "bar"); + } + + // This does the actual testing. SSTabRestoring should be firing on tabs from + // left to right, so we're going to start with the rightmost tab. + function onFirstSSTabRestoring() { + info("onFirstSSTabRestoring..."); + for (let i = gBrowser.tabs.length - 1; i >= 0; i--) { + let tab = gBrowser.tabs[i]; + let actualUniq = ss.getCustomTabValue(tab, "uniq"); + let expectedUniq = state.windows[0].tabs[i].extData.uniq; + + if (wasLoaded[actualUniq]) { + info("tab " + i + ": already restored"); + continue; + } + is(actualUniq, expectedUniq, "tab " + i + ": extData was correct"); + + // Now we're going to set a piece of data back on the tab so it can be read + // to test setting a value "early". + uniq2[actualUniq] = r(); + ss.setCustomTabValue(tab, "uniq2", uniq2[actualUniq]); + + // Delete the value we have for "baz". This tests that deleteCustomTabValue + // will delete "early access" values (c.f. bug 617175). If this doesn't throw + // then the test is successful. + try { + ss.deleteCustomTabValue(tab, "baz"); + } catch (e) { + ok(false, "no error calling deleteCustomTabValue - " + e); + } + + // This will be used in the final comparison to make sure we checked the + // same number as we set. + uniq2Count++; + } + } + + function onLastSSTabRestoring() { + let checked = 0; + for (let i = 0; i < gBrowser.tabs.length; i++) { + let tab = gBrowser.tabs[i]; + let uniq = ss.getCustomTabValue(tab, "uniq"); + + // Look to see if we set a uniq2 value for this uniq value + if (uniq in uniq2) { + is( + ss.getCustomTabValue(tab, "uniq2"), + uniq2[uniq], + "tab " + i + " has correct uniq2 value" + ); + checked++; + } + } + ok(uniq2Count > 0, "at least 1 tab properly checked 'early access'"); + is(checked, uniq2Count, "checked the same number of uniq2 as we set"); + } + + function cleanup() { + // remove the event listener and clean up before finishing + gBrowser.tabContainer.removeEventListener( + "SSTabRestoring", + onSSTabRestoring + ); + gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + onSSTabRestored, + true + ); + gBrowser.tabContainer.removeEventListener("TabOpen", onTabOpen); + // Put this in an executeSoon because we still haven't called restoreNextTab + // in sessionstore for the last tab (we'll call it after this). We end up + // trying to restore the tab (since we then add a closed tab to the array). + executeSoon(function() { + ss.setBrowserState(stateBackup); + executeSoon(finish); + }); + } + + // Add the event listeners + gBrowser.tabContainer.addEventListener("SSTabRestoring", onSSTabRestoring); + gBrowser.tabContainer.addEventListener( + "SSTabRestored", + onSSTabRestored, + true + ); + gBrowser.tabContainer.addEventListener("TabOpen", onTabOpen); + // Restore state + ss.setBrowserState(JSON.stringify(state)); +} diff --git a/browser/components/sessionstore/test/browser_590563.js b/browser/components/sessionstore/test/browser_590563.js new file mode 100644 index 0000000000..7507c8bdc3 --- /dev/null +++ b/browser/components/sessionstore/test/browser_590563.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function() { + let sessionData = { + windows: [ + { + tabs: [ + { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + hidden: true, + }, + { + entries: [{ url: "about:blank", triggeringPrincipal_base64 }], + hidden: false, + }, + ], + }, + ], + }; + let url = "about:sessionrestore"; + let formdata = { id: { sessionData }, url }; + let state = { + windows: [ + { tabs: [{ entries: [{ url, triggeringPrincipal_base64 }], formdata }] }, + ], + }; + + let win = await newWindowWithState(state); + + registerCleanupFunction(() => BrowserTestUtils.closeWindow(win)); + + is(gBrowser.tabs.length, 1, "The total number of tabs should be 1"); + is( + gBrowser.visibleTabs.length, + 1, + "The total number of visible tabs should be 1" + ); + + await new Promise(resolve => executeSoon(resolve)); + await new Promise(resolve => waitForFocus(resolve, win)); + await middleClickTest(win); +}); + +async function middleClickTest(win) { + let browser = win.gBrowser.selectedBrowser; + let tabsToggle = browser.contentDocument.getElementById("tabsToggle"); + EventUtils.synthesizeMouseAtCenter( + tabsToggle, + { button: 0 }, + browser.contentWindow + ); + let tree = browser.contentDocument.getElementById("tabList"); + await BrowserTestUtils.waitForCondition(() => !tree.hasAttribute("hidden")); + // Force a layout flush before accessing coordinates. + tree.getBoundingClientRect(); + + is(tree.view.rowCount, 3, "There should be three items"); + + // click on the first tab item + var rect = tree.getCoordsForCellItem(1, tree.columns[1], "text"); + EventUtils.synthesizeMouse( + tree.body, + rect.x, + rect.y, + { button: 1 }, + browser.contentWindow + ); + // click on the second tab item + rect = tree.getCoordsForCellItem(2, tree.columns[1], "text"); + EventUtils.synthesizeMouse( + tree.body, + rect.x, + rect.y, + { button: 1 }, + browser.contentWindow + ); + + is( + win.gBrowser.tabs.length, + 3, + "The total number of tabs should be 3 after restoring 2 tabs by middle click." + ); + is( + win.gBrowser.visibleTabs.length, + 3, + "The total number of visible tabs should be 3 after restoring 2 tabs by middle click" + ); +} + +async function newWindowWithState(state) { + let opts = "chrome,all,dialog=no,height=800,width=800"; + let win = window.openDialog(AppConstants.BROWSER_CHROME_URL, "_blank", opts); + + await BrowserTestUtils.waitForEvent(win, "load"); + + // The form data will be restored before SSTabRestored, so we want to listen + // for that on the currently selected tab + await new Promise(resolve => { + win.gBrowser.tabContainer.addEventListener( + "SSTabRestored", + function onSSTabRestored(event) { + let tab = event.target; + if (tab.selected) { + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + onSSTabRestored, + true + ); + resolve(); + } + }, + true + ); + + executeSoon(() => ss.setWindowState(win, JSON.stringify(state), true)); + }); + + return win; +} diff --git a/browser/components/sessionstore/test/browser_595601-restore_hidden.js b/browser/components/sessionstore/test/browser_595601-restore_hidden.js new file mode 100644 index 0000000000..b8d49934a0 --- /dev/null +++ b/browser/components/sessionstore/test/browser_595601-restore_hidden.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var windowState = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.com#1", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#2", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#3", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#4", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#5", triggeringPrincipal_base64 }, + ], + hidden: true, + }, + { + entries: [ + { url: "http://example.com#6", triggeringPrincipal_base64 }, + ], + hidden: true, + }, + { + entries: [ + { url: "http://example.com#7", triggeringPrincipal_base64 }, + ], + hidden: true, + }, + { + entries: [ + { url: "http://example.com#8", triggeringPrincipal_base64 }, + ], + hidden: true, + }, + ], + }, + ], +}; + +function test() { + waitForExplicitFinish(); + requestLongerTimeout(2); + + registerCleanupFunction(function() { + Services.prefs.clearUserPref("browser.sessionstore.restore_hidden_tabs"); + }); + + // First stage: restoreHiddenTabs = true + // Second stage: restoreHiddenTabs = false + test_loadTabs(true, function() { + test_loadTabs(false, finish); + }); +} + +function test_loadTabs(restoreHiddenTabs, callback) { + Services.prefs.setBoolPref( + "browser.sessionstore.restore_hidden_tabs", + restoreHiddenTabs + ); + + let expectedTabs = restoreHiddenTabs ? 8 : 4; + let firstProgress = true; + + newWindowWithState(windowState, function(win, needsRestore, isRestoring) { + if (firstProgress) { + firstProgress = false; + is(isRestoring, 3, "restoring 3 tabs concurrently"); + } else { + ok(isRestoring < 4, "restoring max. 3 tabs concurrently"); + } + + // We're explicity checking for (isRestoring == 1) here because the test + // progress listener is called before the session store one. So when we're + // called with one tab left to restore we know that the last tab has + // finished restoring and will soon be handled by the SS listener. + let tabsNeedingRestore = win.gBrowser.tabs.length - needsRestore; + if (isRestoring == 1 && tabsNeedingRestore == expectedTabs) { + is(win.gBrowser.visibleTabs.length, 4, "only 4 visible tabs"); + + TabsProgressListener.uninit(); + executeSoon(callback); + } + }); +} + +var TabsProgressListener = { + init(win) { + this.window = win; + Services.obs.addObserver(this, "sessionstore-debug-tab-restored"); + }, + + uninit() { + Services.obs.removeObserver(this, "sessionstore-debug-tab-restored"); + + delete this.window; + delete this.callback; + }, + + setCallback(callback) { + this.callback = callback; + }, + + observe(browser) { + TabsProgressListener.onRestored(browser); + }, + + onRestored(browser) { + if ( + this.callback && + ss.getInternalObjectState(browser) == TAB_STATE_RESTORING + ) { + this.callback.apply(null, [this.window].concat(this.countTabs())); + } + }, + + countTabs() { + let needsRestore = 0, + isRestoring = 0; + + for (let i = 0; i < this.window.gBrowser.tabs.length; i++) { + let state = ss.getInternalObjectState( + this.window.gBrowser.tabs[i].linkedBrowser + ); + if (state == TAB_STATE_RESTORING) { + isRestoring++; + } else if (state == TAB_STATE_NEEDS_RESTORE) { + needsRestore++; + } + } + + return [needsRestore, isRestoring]; + }, +}; + +// ---------- +function newWindowWithState(state, callback) { + let opts = "chrome,all,dialog=no,height=800,width=800"; + let win = window.openDialog(AppConstants.BROWSER_CHROME_URL, "_blank", opts); + + registerCleanupFunction(() => BrowserTestUtils.closeWindow(win)); + + whenWindowLoaded(win, function onWindowLoaded(aWin) { + TabsProgressListener.init(aWin); + TabsProgressListener.setCallback(callback); + + ss.setWindowState(aWin, JSON.stringify(state), true); + }); +} diff --git a/browser/components/sessionstore/test/browser_597071.js b/browser/components/sessionstore/test/browser_597071.js new file mode 100644 index 0000000000..953e462491 --- /dev/null +++ b/browser/components/sessionstore/test/browser_597071.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Bug 597071 - Closed windows should only be resurrected when there is a single + * popup window + */ +add_task(async function test_close_last_nonpopup_window() { + // Purge the list of closed windows. + forgetClosedWindows(); + + let oldState = ss.getWindowState(window); + + let popupState = { + windows: [{ tabs: [{ entries: [] }], isPopup: true, hidden: "toolbar" }], + }; + + // Set this window to be a popup. + ss.setWindowState(window, JSON.stringify(popupState), true); + + // Open a new window with a tab. + let win = await BrowserTestUtils.openNewBrowserWindow({ private: false }); + let tab = BrowserTestUtils.addTab(win.gBrowser, "http://example.com/"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + // Make sure sessionstore sees this window. + let state = JSON.parse(ss.getBrowserState()); + is(state.windows.length, 2, "sessionstore knows about this window"); + + // Closed the window and check the closed window count. + await BrowserTestUtils.closeWindow(win); + is(ss.getClosedWindowCount(), 1, "correct closed window count"); + + // Cleanup. + ss.setWindowState(window, oldState, true); +}); diff --git a/browser/components/sessionstore/test/browser_600545.js b/browser/components/sessionstore/test/browser_600545.js new file mode 100644 index 0000000000..f06142632c --- /dev/null +++ b/browser/components/sessionstore/test/browser_600545.js @@ -0,0 +1,123 @@ +/* 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/. */ + +requestLongerTimeout(2); + +var stateBackup = JSON.parse(ss.getBrowserState()); + +function test() { + /** Test for Bug 600545 **/ + waitForExplicitFinish(); + testBug600545(); +} + +function testBug600545() { + // Set the pref to false to cause non-app tabs to be stripped out on a save + Services.prefs.setBoolPref("browser.sessionstore.resume_from_crash", false); + Services.prefs.setIntPref("browser.sessionstore.interval", 2000); + + registerCleanupFunction(function() { + Services.prefs.clearUserPref("browser.sessionstore.resume_from_crash"); + Services.prefs.clearUserPref("browser.sessionstore.interval"); + }); + + // This tests the following use case: When multiple windows are open + // and browser.sessionstore.resume_from_crash preference is false, + // tab session data for non-active window is stripped for non-pinned + // tabs. This occurs after "sessionstore-state-write-complete" + // fires which will only fire in this case if there is at least one + // pinned tab. + let state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org#0", triggeringPrincipal_base64 }, + ], + pinned: true, + }, + { + entries: [ + { url: "http://example.com#1", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#2", triggeringPrincipal_base64 }, + ], + }, + ], + selected: 2, + }, + { + tabs: [ + { + entries: [ + { url: "http://example.com#3", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#4", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#5", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#6", triggeringPrincipal_base64 }, + ], + }, + ], + selected: 3, + }, + ], + }; + + waitForBrowserState(state, function() { + // Need to wait for SessionStore's saveState function to be called + // so that non-pinned tabs will be stripped from non-active window + waitForSaveState(function() { + let expectedNumberOfTabs = getStateTabCount(state); + let retrievedState = JSON.parse(ss.getBrowserState()); + let actualNumberOfTabs = getStateTabCount(retrievedState); + + is( + actualNumberOfTabs, + expectedNumberOfTabs, + "Number of tabs in retreived session data, matches number of tabs set." + ); + + done(); + }); + }); +} + +function done() { + // Enumerate windows and close everything but our primary window. We can't + // use waitForFocus() because apparently it's buggy. See bug 599253. + let closeWinPromises = []; + for (let currentWindow of Services.wm.getEnumerator("navigator:browser")) { + if (currentWindow != window) { + closeWinPromises.push(BrowserTestUtils.closeWindow(currentWindow)); + } + } + + Promise.all(closeWinPromises).then(() => { + waitForBrowserState(stateBackup, finish); + }); +} + +// Count up the number of tabs in the state data +function getStateTabCount(aState) { + let tabCount = 0; + for (let i in aState.windows) { + tabCount += aState.windows[i].tabs.length; + } + return tabCount; +} diff --git a/browser/components/sessionstore/test/browser_601955.js b/browser/components/sessionstore/test/browser_601955.js new file mode 100644 index 0000000000..90cf6c182b --- /dev/null +++ b/browser/components/sessionstore/test/browser_601955.js @@ -0,0 +1,54 @@ +/* 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/. */ + +// This tests that pinning/unpinning a tab, on its own, eventually triggers a +// session store. + +function test() { + waitForExplicitFinish(); + // We speed up the interval between session saves to ensure that the test + // runs quickly. + Services.prefs.setIntPref("browser.sessionstore.interval", 2000); + + // Loading a tab causes a save state and this is meant to catch that event. + waitForSaveState(testBug601955_1); + + // Assumption: Only one window is open and it has one tab open. + BrowserTestUtils.addTab(gBrowser, "about:mozilla"); +} + +function testBug601955_1() { + // Because pinned tabs are at the front of |gBrowser.tabs|, pinning tabs + // re-arranges the |tabs| array. + ok(!gBrowser.tabs[0].pinned, "first tab should not be pinned yet"); + ok(!gBrowser.tabs[1].pinned, "second tab should not be pinned yet"); + + waitForSaveState(testBug601955_2); + gBrowser.pinTab(gBrowser.tabs[0]); +} + +function testBug601955_2() { + let state = JSON.parse(ss.getBrowserState()); + ok(state.windows[0].tabs[0].pinned, "first tab should be pinned by now"); + ok(!state.windows[0].tabs[1].pinned, "second tab should still not be pinned"); + + waitForSaveState(testBug601955_3); + gBrowser.unpinTab(window.gBrowser.tabs[0]); +} + +function testBug601955_3() { + let state = JSON.parse(ss.getBrowserState()); + ok(!state.windows[0].tabs[0].pinned, "first tab should not be pinned"); + ok(!state.windows[0].tabs[1].pinned, "second tab should not be pinned"); + + done(); +} + +function done() { + gBrowser.removeTab(window.gBrowser.tabs[1]); + + Services.prefs.clearUserPref("browser.sessionstore.interval"); + + executeSoon(finish); +} diff --git a/browser/components/sessionstore/test/browser_607016.js b/browser/components/sessionstore/test/browser_607016.js new file mode 100644 index 0000000000..2dc2662078 --- /dev/null +++ b/browser/components/sessionstore/test/browser_607016.js @@ -0,0 +1,155 @@ +"use strict"; + +var stateBackup = ss.getBrowserState(); + +add_task(async function() { + /** Bug 607016 - If a tab is never restored, attributes (eg. hidden) aren't updated correctly **/ + ignoreAllUncaughtExceptions(); + + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + // Don't restore tabs lazily. + Services.prefs.setBoolPref("browser.sessionstore.restore_tabs_lazily", false); + + let state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org#1", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#2", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, // overwriting + { + entries: [ + { url: "http://example.org#3", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, // hiding + { + entries: [ + { url: "http://example.org#4", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, // adding + { + entries: [ + { url: "http://example.org#5", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, // deleting + { + entries: [ + { url: "http://example.org#6", triggeringPrincipal_base64 }, + ], + }, // creating + ], + selected: 1, + }, + ], + }; + + async function progressCallback() { + let curState = JSON.parse(ss.getBrowserState()); + for (let i = 0; i < curState.windows[0].tabs.length; i++) { + let tabState = state.windows[0].tabs[i]; + let tabCurState = curState.windows[0].tabs[i]; + if (tabState.extData) { + is( + tabCurState.extData.uniq, + tabState.extData.uniq, + "sanity check that tab has correct extData" + ); + } else { + // We aren't expecting there to be any data on extData, but panorama + // may be setting something, so we need to make sure that if we do have + // data, we just don't have anything for "uniq". + ok( + !("extData" in tabCurState) || !("uniq" in tabCurState.extData), + "sanity check that tab doesn't have extData or extData doesn't have 'uniq'" + ); + } + } + + // Now we'll set a new unique value on 1 of the tabs + let newUniq = r(); + ss.setCustomTabValue(gBrowser.tabs[1], "uniq", newUniq); + let tabState = JSON.parse(ss.getTabState(gBrowser.tabs[1])); + is( + tabState.extData.uniq, + newUniq, + "(overwriting) new data is stored in extData" + ); + + // hide the next tab before closing it + gBrowser.hideTab(gBrowser.tabs[2]); + tabState = JSON.parse(ss.getTabState(gBrowser.tabs[2])); + ok(tabState.hidden, "(hiding) tab data has hidden == true"); + + // set data that's not in a conflicting key + let stillUniq = r(); + ss.setCustomTabValue(gBrowser.tabs[3], "stillUniq", stillUniq); + tabState = JSON.parse(ss.getTabState(gBrowser.tabs[3])); + is( + tabState.extData.stillUniq, + stillUniq, + "(adding) new data is stored in extData" + ); + + // remove the uniq value and make sure it's not there in the closed data + ss.deleteCustomTabValue(gBrowser.tabs[4], "uniq"); + tabState = JSON.parse(ss.getTabState(gBrowser.tabs[4])); + // Since Panorama might have put data in, first check if there is extData. + // If there is explicitly check that "uniq" isn't in it. Otherwise, we're ok + if ("extData" in tabState) { + ok( + !("uniq" in tabState.extData), + "(deleting) uniq not in existing extData" + ); + } else { + ok(true, "(deleting) no data is stored in extData"); + } + + // set unique data on the tab that never had any set, make sure that's saved + let newUniq2 = r(); + ss.setCustomTabValue(gBrowser.tabs[5], "uniq", newUniq2); + tabState = JSON.parse(ss.getTabState(gBrowser.tabs[5])); + is( + tabState.extData.uniq, + newUniq2, + "(creating) new data is stored in extData where there was none" + ); + + while (gBrowser.tabs.length > 1) { + BrowserTestUtils.removeTab(gBrowser.tabs[1]); + } + } + + // Set the test state. + await setBrowserState(state); + + // Wait until the selected tab is restored and all others are pending. + await Promise.all( + Array.from(gBrowser.tabs, tab => { + return tab == gBrowser.selectedTab + ? promiseTabRestored(tab) + : promiseTabRestoring(tab); + }) + ); + + // Kick off the actual tests. + await progressCallback(); + + // Cleanup. + Services.prefs.clearUserPref("browser.sessionstore.restore_on_demand"); + Services.prefs.clearUserPref("browser.sessionstore.restore_tabs_lazily"); + await promiseBrowserState(stateBackup); +}); diff --git a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_duplicateTab.js b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_duplicateTab.js new file mode 100644 index 0000000000..b3ad6d240a --- /dev/null +++ b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_duplicateTab.js @@ -0,0 +1,69 @@ +/* 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/. */ + +const testState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:rights", triggeringPrincipal_base64 }] }, + ], + }, + ], +}; + +function test() { + /** Test for Bug 615394 - Session Restore should notify when it is beginning and ending a restore **/ + waitForExplicitFinish(); + + waitForBrowserState(testState, test_duplicateTab); +} + +function test_duplicateTab() { + let tab = gBrowser.tabs[1]; + let busyEventCount = 0; + let readyEventCount = 0; + let newTab; + + // We'll look to make sure this value is on the duplicated tab + ss.setCustomTabValue(tab, "foo", "bar"); + + function onSSWindowStateBusy(aEvent) { + busyEventCount++; + } + + function onSSWindowStateReady(aEvent) { + newTab = gBrowser.tabs[2]; + readyEventCount++; + is(ss.getCustomTabValue(newTab, "foo"), "bar"); + ss.setCustomTabValue(newTab, "baz", "qux"); + } + + function onSSTabRestoring(aEvent) { + if (aEvent.target == newTab) { + is(busyEventCount, 1); + is(readyEventCount, 1); + is(ss.getCustomTabValue(newTab, "baz"), "qux"); + is(newTab.linkedBrowser.currentURI.spec, "about:rights"); + + window.removeEventListener("SSWindowStateBusy", onSSWindowStateBusy); + window.removeEventListener("SSWindowStateReady", onSSWindowStateReady); + gBrowser.tabContainer.removeEventListener( + "SSTabRestoring", + onSSTabRestoring + ); + + gBrowser.removeTab(tab); + gBrowser.removeTab(newTab); + finish(); + } + } + + window.addEventListener("SSWindowStateBusy", onSSWindowStateBusy); + window.addEventListener("SSWindowStateReady", onSSWindowStateReady); + gBrowser.tabContainer.addEventListener("SSTabRestoring", onSSTabRestoring); + + gBrowser._insertBrowser(tab); + newTab = ss.duplicateTab(window, tab); +} diff --git a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setBrowserState.js b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setBrowserState.js new file mode 100644 index 0000000000..24e7b164ea --- /dev/null +++ b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setBrowserState.js @@ -0,0 +1,152 @@ +/* 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/. */ + +const lameMultiWindowState = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org#1", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#2", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#3", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#4", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 1, + }, + { + tabs: [ + { + entries: [ + { url: "http://example.com#1", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#2", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#3", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#4", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 3, + }, + ], +}; + +function getOuterWindowID(aWindow) { + return aWindow.docShell.outerWindowID; +} + +function test() { + /** Test for Bug 615394 - Session Restore should notify when it is beginning and ending a restore **/ + waitForExplicitFinish(); + + // We'll track events per window so we are sure that they are each happening once + // pre window. + let windowEvents = {}; + windowEvents[getOuterWindowID(window)] = { + busyEventCount: 0, + readyEventCount: 0, + }; + + // waitForBrowserState does it's own observing for windows, but doesn't attach + // the listeners we want here, so do it ourselves. + let newWindow; + function windowObserver(aSubject, aTopic, aData) { + if (aTopic == "domwindowopened") { + Services.ww.unregisterNotification(windowObserver); + + newWindow = aSubject; + newWindow.addEventListener( + "load", + function() { + windowEvents[getOuterWindowID(newWindow)] = { + busyEventCount: 0, + readyEventCount: 0, + }; + + newWindow.addEventListener("SSWindowStateBusy", onSSWindowStateBusy); + newWindow.addEventListener( + "SSWindowStateReady", + onSSWindowStateReady + ); + }, + { once: true } + ); + } + } + + function onSSWindowStateBusy(aEvent) { + windowEvents[getOuterWindowID(aEvent.originalTarget)].busyEventCount++; + } + + function onSSWindowStateReady(aEvent) { + windowEvents[getOuterWindowID(aEvent.originalTarget)].readyEventCount++; + } + + window.addEventListener("SSWindowStateBusy", onSSWindowStateBusy); + window.addEventListener("SSWindowStateReady", onSSWindowStateReady); + Services.ww.registerNotification(windowObserver); + + waitForBrowserState(lameMultiWindowState, function() { + let checkedWindows = 0; + for (let id of Object.keys(windowEvents)) { + let winEvents = windowEvents[id]; + is( + winEvents.busyEventCount, + 1, + "window" + id + " busy event count correct" + ); + is( + winEvents.readyEventCount, + 1, + "window" + id + " ready event count correct" + ); + checkedWindows++; + } + is(checkedWindows, 2, "checked 2 windows"); + window.removeEventListener("SSWindowStateBusy", onSSWindowStateBusy); + window.removeEventListener("SSWindowStateReady", onSSWindowStateReady); + newWindow.removeEventListener("SSWindowStateBusy", onSSWindowStateBusy); + newWindow.removeEventListener("SSWindowStateReady", onSSWindowStateReady); + + newWindow.close(); + while (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.tabs[1]); + } + + finish(); + }); +} diff --git a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setTabState.js b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setTabState.js new file mode 100644 index 0000000000..a76a8b3dd5 --- /dev/null +++ b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setTabState.js @@ -0,0 +1,61 @@ +/* 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/. */ + +const testState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:rights", triggeringPrincipal_base64 }] }, + ], + }, + ], +}; + +function test() { + /** Test for Bug 615394 - Session Restore should notify when it is beginning and ending a restore **/ + waitForExplicitFinish(); + + waitForBrowserState(testState, test_setTabState); +} + +function test_setTabState() { + let tab = gBrowser.tabs[1]; + let newTabState = JSON.stringify({ + entries: [{ url: "http://example.org", triggeringPrincipal_base64 }], + extData: { foo: "bar" }, + }); + let busyEventCount = 0; + let readyEventCount = 0; + + function onSSWindowStateBusy(aEvent) { + busyEventCount++; + } + + function onSSWindowStateReady(aEvent) { + readyEventCount++; + is(ss.getCustomTabValue(tab, "foo"), "bar"); + ss.setCustomTabValue(tab, "baz", "qux"); + } + + function onSSTabRestoring(aEvent) { + is(busyEventCount, 1); + is(readyEventCount, 1); + is(ss.getCustomTabValue(tab, "baz"), "qux"); + is(tab.linkedBrowser.currentURI.spec, "http://example.org/"); + + window.removeEventListener("SSWindowStateBusy", onSSWindowStateBusy); + window.removeEventListener("SSWindowStateReady", onSSWindowStateReady); + + gBrowser.removeTab(tab); + finish(); + } + + window.addEventListener("SSWindowStateBusy", onSSWindowStateBusy); + window.addEventListener("SSWindowStateReady", onSSWindowStateReady); + tab.addEventListener("SSTabRestoring", onSSTabRestoring, { once: true }); + // Browser must be inserted in order to restore. + gBrowser._insertBrowser(tab); + ss.setTabState(tab, newTabState); +} diff --git a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setWindowState.js b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setWindowState.js new file mode 100644 index 0000000000..c9d4bd00f5 --- /dev/null +++ b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_setWindowState.js @@ -0,0 +1,65 @@ +/* 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/. */ + +function test() { + /** Test for Bug 615394 - Session Restore should notify when it is beginning and ending a restore **/ + waitForExplicitFinish(); + + let newState = { + windows: [ + { + tabs: [ + { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + extData: { foo: "bar" }, + }, + { + entries: [ + { url: "http://example.org", triggeringPrincipal_base64 }, + ], + extData: { baz: "qux" }, + }, + ], + }, + ], + }; + + let busyEventCount = 0, + readyEventCount = 0, + tabRestoredCount = 0; + + function onSSWindowStateBusy(aEvent) { + busyEventCount++; + } + + function onSSWindowStateReady(aEvent) { + readyEventCount++; + is(ss.getCustomTabValue(gBrowser.tabs[0], "foo"), "bar"); + is(ss.getCustomTabValue(gBrowser.tabs[1], "baz"), "qux"); + } + + function onSSTabRestored(aEvent) { + if (++tabRestoredCount < 2) { + return; + } + + is(busyEventCount, 1); + is(readyEventCount, 1); + is(gBrowser.tabs[0].linkedBrowser.currentURI.spec, "about:mozilla"); + is(gBrowser.tabs[1].linkedBrowser.currentURI.spec, "http://example.org/"); + + window.removeEventListener("SSWindowStateBusy", onSSWindowStateBusy); + window.removeEventListener("SSWindowStateReady", onSSWindowStateReady); + gBrowser.tabContainer.removeEventListener("SSTabRestored", onSSTabRestored); + + gBrowser.removeTab(gBrowser.tabs[1]); + finish(); + } + + window.addEventListener("SSWindowStateBusy", onSSWindowStateBusy); + window.addEventListener("SSWindowStateReady", onSSWindowStateReady); + gBrowser.tabContainer.addEventListener("SSTabRestored", onSSTabRestored); + + ss.setWindowState(window, JSON.stringify(newState), true); +} diff --git a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseTab.js b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseTab.js new file mode 100644 index 0000000000..345bba516c --- /dev/null +++ b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseTab.js @@ -0,0 +1,65 @@ +"use strict"; + +const testState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:rights", triggeringPrincipal_base64 }] }, + ], + }, + ], +}; + +// Test for Bug 615394 - Session Restore should notify when it is beginning and +// ending a restore. +add_task(async function test_undoCloseTab() { + await promiseBrowserState(testState); + + let tab = gBrowser.tabs[1]; + let busyEventCount = 0; + let readyEventCount = 0; + // This will be set inside the `onSSWindowStateReady` method. + let lastTab; + + ss.setCustomTabValue(tab, "foo", "bar"); + + function onSSWindowStateBusy(aEvent) { + busyEventCount++; + } + + function onSSWindowStateReady(aEvent) { + Assert.equal(gBrowser.tabs.length, 2, "Should only have 2 tabs"); + lastTab = gBrowser.tabs[1]; + readyEventCount++; + Assert.equal(ss.getCustomTabValue(lastTab, "foo"), "bar"); + ss.setCustomTabValue(lastTab, "baz", "qux"); + } + + window.addEventListener("SSWindowStateBusy", onSSWindowStateBusy); + window.addEventListener("SSWindowStateReady", onSSWindowStateReady); + + let restoredPromise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "SSTabRestored" + ); + + await promiseRemoveTabAndSessionState(tab); + let reopenedTab = ss.undoCloseTab(window, 0); + + await Promise.all([ + restoredPromise, + BrowserTestUtils.browserLoaded(reopenedTab.linkedBrowser), + ]); + + Assert.equal(reopenedTab, lastTab, "Tabs should be the same one."); + Assert.equal(busyEventCount, 1); + Assert.equal(readyEventCount, 1); + Assert.equal(ss.getCustomTabValue(reopenedTab, "baz"), "qux"); + Assert.equal(reopenedTab.linkedBrowser.currentURI.spec, "about:rights"); + + window.removeEventListener("SSWindowStateBusy", onSSWindowStateBusy); + window.removeEventListener("SSWindowStateReady", onSSWindowStateReady); + + BrowserTestUtils.removeTab(reopenedTab); +}); diff --git a/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseWindow.js b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseWindow.js new file mode 100644 index 0000000000..41364fb971 --- /dev/null +++ b/browser/components/sessionstore/test/browser_615394-SSWindowState_events_undoCloseWindow.js @@ -0,0 +1,146 @@ +/* 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/. */ + +const lameMultiWindowState = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org#1", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#2", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#3", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.org#4", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 1, + }, + { + tabs: [ + { + entries: [ + { url: "http://example.com#1", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#2", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#3", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + { + entries: [ + { url: "http://example.com#4", triggeringPrincipal_base64 }, + ], + extData: { uniq: r() }, + }, + ], + selected: 3, + }, + ], +}; + +function test() { + /** Test for Bug 615394 - Session Restore should notify when it is beginning and ending a restore **/ + waitForExplicitFinish(); + + let newWindow, reopenedWindow; + + function firstWindowObserver(aSubject, aTopic, aData) { + if (aTopic == "domwindowopened") { + newWindow = aSubject; + Services.ww.unregisterNotification(firstWindowObserver); + } + } + Services.ww.registerNotification(firstWindowObserver); + + waitForBrowserState(lameMultiWindowState, function() { + // Close the window which isn't window + BrowserTestUtils.closeWindow(newWindow).then(() => { + // Now give it time to close + reopenedWindow = ss.undoCloseWindow(0); + reopenedWindow.addEventListener("SSWindowStateBusy", onSSWindowStateBusy); + reopenedWindow.addEventListener( + "SSWindowStateReady", + onSSWindowStateReady + ); + + reopenedWindow.addEventListener( + "load", + function() { + reopenedWindow.gBrowser.tabContainer.addEventListener( + "SSTabRestored", + onSSTabRestored + ); + }, + { once: true } + ); + }); + }); + + let busyEventCount = 0, + readyEventCount = 0, + tabRestoredCount = 0; + // These will listen to the reopened closed window... + function onSSWindowStateBusy(aEvent) { + busyEventCount++; + } + + function onSSWindowStateReady(aEvent) { + readyEventCount++; + } + + function onSSTabRestored(aEvent) { + if (++tabRestoredCount < 4) { + return; + } + + is(busyEventCount, 1); + is(readyEventCount, 1); + + reopenedWindow.removeEventListener( + "SSWindowStateBusy", + onSSWindowStateBusy + ); + reopenedWindow.removeEventListener( + "SSWindowStateReady", + onSSWindowStateReady + ); + reopenedWindow.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + onSSTabRestored + ); + + reopenedWindow.close(); + while (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.tabs[1]); + } + + finish(); + } +} diff --git a/browser/components/sessionstore/test/browser_618151.js b/browser/components/sessionstore/test/browser_618151.js new file mode 100644 index 0000000000..c38a349818 --- /dev/null +++ b/browser/components/sessionstore/test/browser_618151.js @@ -0,0 +1,67 @@ +/* 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/. */ + +const stateBackup = ss.getBrowserState(); +const testState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }] }, + ], + }, + ], +}; + +function test() { + /** Test for Bug 618151 - Overwriting state can lead to unrestored tabs **/ + waitForExplicitFinish(); + runNextTest(); +} + +// Just a subset of tests from bug 615394 that causes a timeout. +var tests = [test_setup, test_hang]; +function runNextTest() { + // set an empty state & run the next test, or finish + if (tests.length) { + // Enumerate windows and close everything but our primary window. We can't + // use waitForFocus() because apparently it's buggy. See bug 599253. + let closeWinPromises = []; + for (let currentWindow of Services.wm.getEnumerator("navigator:browser")) { + if (currentWindow != window) { + closeWinPromises.push(BrowserTestUtils.closeWindow(currentWindow)); + } + } + + Promise.all(closeWinPromises).then(() => { + let currentTest = tests.shift(); + info("running " + currentTest.name); + waitForBrowserState(testState, currentTest); + }); + } else { + ss.setBrowserState(stateBackup); + executeSoon(finish); + } +} + +function test_setup() { + function onSSTabRestored(aEvent) { + gBrowser.tabContainer.removeEventListener("SSTabRestored", onSSTabRestored); + runNextTest(); + } + + gBrowser.tabContainer.addEventListener("SSTabRestored", onSSTabRestored); + ss.setTabState( + gBrowser.tabs[1], + JSON.stringify({ + entries: [{ url: "http://example.org", triggeringPrincipal_base64 }], + extData: { foo: "bar" }, + }) + ); +} + +function test_hang() { + ok(true, "test didn't time out"); + runNextTest(); +} diff --git a/browser/components/sessionstore/test/browser_623779.js b/browser/components/sessionstore/test/browser_623779.js new file mode 100644 index 0000000000..f82fa2bf81 --- /dev/null +++ b/browser/components/sessionstore/test/browser_623779.js @@ -0,0 +1,13 @@ +"use strict"; + +add_task(async function() { + gBrowser.pinTab(gBrowser.selectedTab); + + let newTab = gBrowser.duplicateTab(gBrowser.selectedTab); + await promiseTabRestored(newTab); + + ok(!newTab.pinned, "duplicating a pinned tab creates unpinned tab"); + BrowserTestUtils.removeTab(newTab); + + gBrowser.unpinTab(gBrowser.selectedTab); +}); diff --git a/browser/components/sessionstore/test/browser_624727.js b/browser/components/sessionstore/test/browser_624727.js new file mode 100644 index 0000000000..0972429b81 --- /dev/null +++ b/browser/components/sessionstore/test/browser_624727.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var TEST_STATE = { windows: [{ tabs: [{ url: "about:blank" }] }] }; + +add_task(async function() { + function assertNumberOfTabs(num, msg) { + is(gBrowser.tabs.length, num, msg); + } + + function assertNumberOfPinnedTabs(num, msg) { + is(gBrowser._numPinnedTabs, num, msg); + } + + // check prerequisites + assertNumberOfTabs(1, "we start off with one tab"); + assertNumberOfPinnedTabs(0, "no pinned tabs so far"); + + // setup + BrowserTestUtils.addTab(gBrowser, "about:blank"); + assertNumberOfTabs(2, "there are two tabs, now"); + + let [tab1, tab2] = gBrowser.tabs; + gBrowser.pinTab(tab1); + gBrowser.pinTab(tab2); + assertNumberOfPinnedTabs(2, "both tabs are now pinned"); + + // run the test + await promiseBrowserState(TEST_STATE); + + assertNumberOfTabs(1, "one tab left after setBrowserState()"); + assertNumberOfPinnedTabs(0, "there are no pinned tabs"); +}); diff --git a/browser/components/sessionstore/test/browser_625016.js b/browser/components/sessionstore/test/browser_625016.js new file mode 100644 index 0000000000..e5f32d40df --- /dev/null +++ b/browser/components/sessionstore/test/browser_625016.js @@ -0,0 +1,103 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function() { + /** Test for Bug 625016 - Restore windows closed in succession to quit (non-OSX only) **/ + + // We'll test this by opening a new window, waiting for the save + // event, then closing that window. We'll observe the + // "sessionstore-state-write-complete" notification and check that + // the state contains no _closedWindows. We'll then add a new tab + // and make sure that the state following that was reset and the + // closed window is now in _closedWindows. + + requestLongerTimeout(2); + + await forceSaveState(); + + // We'll clear all closed windows to make sure our state is clean + // forgetClosedWindow doesn't trigger a delayed save + forgetClosedWindows(); + is(ss.getClosedWindowCount(), 0, "starting with no closed windows"); +}); + +add_task(async function new_window() { + let newWin; + try { + newWin = await promiseNewWindowLoaded(); + let tab = BrowserTestUtils.addTab( + newWin.gBrowser, + "http://example.com/browser_625016.js?" + Math.random() + ); + await promiseBrowserLoaded(tab.linkedBrowser); + + // Double check that we have no closed windows + is(ss.getClosedWindowCount(), 0, "no closed windows on first save"); + + await BrowserTestUtils.closeWindow(newWin); + newWin = null; + + let state = JSON.parse(await promiseRecoveryFileContents()); + is(state.windows.length, 1, "observe1: 1 window in data written to disk"); + is( + state._closedWindows.length, + 0, + "observe1: no closed windows in data written to disk" + ); + + // The API still treats the closed window as closed, so ensure that window is there + is( + ss.getClosedWindowCount(), + 1, + "observe1: 1 closed window according to API" + ); + } finally { + if (newWin) { + await BrowserTestUtils.closeWindow(newWin); + } + await forceSaveState(); + } +}); + +// We'll open a tab, which should trigger another state save which would wipe +// the _shouldRestore attribute from the closed window +add_task(async function new_tab() { + let newTab; + try { + newTab = BrowserTestUtils.addTab(gBrowser, "about:mozilla"); + await promiseBrowserLoaded(newTab.linkedBrowser); + await TabStateFlusher.flush(newTab.linkedBrowser); + + let state = JSON.parse(await promiseRecoveryFileContents()); + is( + state.windows.length, + 1, + "observe2: 1 window in data being written to disk" + ); + is( + state._closedWindows.length, + 1, + "observe2: 1 closed window in data being written to disk" + ); + + // The API still treats the closed window as closed, so ensure that window is there + is( + ss.getClosedWindowCount(), + 1, + "observe2: 1 closed window according to API" + ); + } finally { + if (newTab) { + gBrowser.removeTab(newTab); + } + } +}); + +add_task(async function done() { + // The API still represents the closed window as closed, so we can clear it + // with the API, but just to make sure... + // is(ss.getClosedWindowCount(), 1, "1 closed window according to API"); + await promiseAllButPrimaryWindowClosed(); + forgetClosedWindows(); + Services.prefs.clearUserPref("browser.sessionstore.interval"); +}); diff --git a/browser/components/sessionstore/test/browser_628270.js b/browser/components/sessionstore/test/browser_628270.js new file mode 100644 index 0000000000..36a3acf185 --- /dev/null +++ b/browser/components/sessionstore/test/browser_628270.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +async function test() { + let assertNumberOfTabs = function(num, msg) { + is(gBrowser.tabs.length, num, msg); + }; + + let assertNumberOfVisibleTabs = function(num, msg) { + is(gBrowser.visibleTabs.length, num, msg); + }; + + waitForExplicitFinish(); + + // check prerequisites + assertNumberOfTabs(1, "we start off with one tab"); + + // setup + let tab = BrowserTestUtils.addTab(gBrowser, "about:mozilla"); + + await promiseBrowserLoaded(tab.linkedBrowser); + + // hide the newly created tab + assertNumberOfVisibleTabs(2, "there are two visible tabs"); + gBrowser.showOnlyTheseTabs([gBrowser.tabs[0]]); + assertNumberOfVisibleTabs(1, "there is one visible tab"); + ok(tab.hidden, "newly created tab is now hidden"); + + // close and restore hidden tab + await promiseRemoveTabAndSessionState(tab); + tab = ss.undoCloseTab(window, 0); + + // check that everything was restored correctly, clean up and finish + await promiseBrowserLoaded(tab.linkedBrowser); + is( + tab.linkedBrowser.currentURI.spec, + "about:mozilla", + "restored tab has correct url" + ); + + gBrowser.removeTab(tab); + finish(); +} diff --git a/browser/components/sessionstore/test/browser_635418.js b/browser/components/sessionstore/test/browser_635418.js new file mode 100644 index 0000000000..c932a3b9f4 --- /dev/null +++ b/browser/components/sessionstore/test/browser_635418.js @@ -0,0 +1,58 @@ +/* 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/. */ + +// This tests that hiding/showing a tab, on its own, eventually triggers a +// session store. + +function test() { + waitForExplicitFinish(); + + // We speed up the interval between session saves to ensure that the test + // runs quickly. + Services.prefs.setIntPref("browser.sessionstore.interval", 2000); + + // Loading a tab causes a save state and this is meant to catch that event. + waitForSaveState(testBug635418_1); + + // Assumption: Only one window is open and it has one tab open. + BrowserTestUtils.addTab(gBrowser, "about:mozilla"); +} + +function testBug635418_1() { + ok(!gBrowser.tabs[0].hidden, "first tab should not be hidden"); + ok(!gBrowser.tabs[1].hidden, "second tab should not be hidden"); + + waitForSaveState(testBug635418_2); + + // We can't hide the selected tab, so hide the new one + gBrowser.hideTab(gBrowser.tabs[1]); +} + +function testBug635418_2() { + let state = JSON.parse(ss.getBrowserState()); + ok(!state.windows[0].tabs[0].hidden, "first tab should still not be hidden"); + ok(state.windows[0].tabs[1].hidden, "second tab should be hidden by now"); + + waitForSaveState(testBug635418_3); + gBrowser.showTab(gBrowser.tabs[1]); +} + +function testBug635418_3() { + let state = JSON.parse(ss.getBrowserState()); + ok( + !state.windows[0].tabs[0].hidden, + "first tab should still still not be hidden" + ); + ok(!state.windows[0].tabs[1].hidden, "second tab should not be hidden again"); + + done(); +} + +function done() { + gBrowser.removeTab(window.gBrowser.tabs[1]); + + Services.prefs.clearUserPref("browser.sessionstore.interval"); + + executeSoon(finish); +} diff --git a/browser/components/sessionstore/test/browser_636279.js b/browser/components/sessionstore/test/browser_636279.js new file mode 100644 index 0000000000..83c21d56c8 --- /dev/null +++ b/browser/components/sessionstore/test/browser_636279.js @@ -0,0 +1,140 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var stateBackup = ss.getBrowserState(); + +var statePinned = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.com#1", triggeringPrincipal_base64 }, + ], + pinned: true, + }, + ], + }, + ], +}; + +var state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.com#1", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#2", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#3", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#4", triggeringPrincipal_base64 }, + ], + }, + ], + }, + ], +}; + +function test() { + waitForExplicitFinish(); + + registerCleanupFunction(function() { + TabsProgressListener.uninit(); + ss.setBrowserState(stateBackup); + }); + + TabsProgressListener.init(); + + window.addEventListener( + "SSWindowStateReady", + function() { + let firstProgress = true; + + TabsProgressListener.setCallback(function(needsRestore, isRestoring) { + if (firstProgress) { + firstProgress = false; + is(isRestoring, 3, "restoring 3 tabs concurrently"); + } else { + ok(isRestoring <= 3, "restoring max. 2 tabs concurrently"); + } + + if (0 == needsRestore) { + TabsProgressListener.unsetCallback(); + waitForFocus(finish); + } + }); + + ss.setBrowserState(JSON.stringify(state)); + }, + { once: true } + ); + + ss.setBrowserState(JSON.stringify(statePinned)); +} + +function countTabs() { + let needsRestore = 0, + isRestoring = 0; + for (let window of Services.wm.getEnumerator("navigator:browser")) { + if (window.closed) { + continue; + } + + for (let i = 0; i < window.gBrowser.tabs.length; i++) { + let browserState = ss.getInternalObjectState( + window.gBrowser.tabs[i].linkedBrowser + ); + if (browserState == TAB_STATE_RESTORING) { + isRestoring++; + } else if (browserState == TAB_STATE_NEEDS_RESTORE) { + needsRestore++; + } + } + } + + return [needsRestore, isRestoring]; +} + +var TabsProgressListener = { + init() { + Services.obs.addObserver(this, "sessionstore-debug-tab-restored"); + }, + + uninit() { + Services.obs.removeObserver(this, "sessionstore-debug-tab-restored"); + this.unsetCallback(); + }, + + setCallback(callback) { + this.callback = callback; + }, + + unsetCallback() { + delete this.callback; + }, + + observe(browser, topic, data) { + TabsProgressListener.onRestored(browser); + }, + + onRestored(browser) { + if ( + this.callback && + ss.getInternalObjectState(browser) == TAB_STATE_RESTORING + ) { + this.callback.apply(null, countTabs()); + } + }, +}; diff --git a/browser/components/sessionstore/test/browser_637020.js b/browser/components/sessionstore/test/browser_637020.js new file mode 100644 index 0000000000..46bf062002 --- /dev/null +++ b/browser/components/sessionstore/test/browser_637020.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_URL = + "http://mochi.test:8888/browser/browser/components/" + + "sessionstore/test/browser_637020_slow.sjs"; + +const TEST_STATE = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:robots", triggeringPrincipal_base64 }] }, + ], + }, + { + tabs: [ + { entries: [{ url: TEST_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: TEST_URL, triggeringPrincipal_base64 }] }, + ], + }, + ], +}; + +/** + * This test ensures that windows that have just been restored will be marked + * as dirty, otherwise _getCurrentState() might ignore them when collecting + * state for the first time and we'd just save them as empty objects. + * + * The dirty state acts as a cache to not collect data from all windows all the + * time, so at the beginning, each window must be dirty so that we collect + * their state at least once. + */ + +add_task(async function test() { + // Wait until the new window has been opened. + let promiseWindow = new Promise(resolve => { + Services.obs.addObserver(function onOpened(subject) { + Services.obs.removeObserver(onOpened, "domwindowopened"); + resolve(subject); + }, "domwindowopened"); + }); + + // Set the new browser state that will + // restore a window with two slowly loading tabs. + let backupState = SessionStore.getBrowserState(); + SessionStore.setBrowserState(JSON.stringify(TEST_STATE)); + let win = await promiseWindow; + let restoring = promiseWindowRestoring(win); + let restored = promiseWindowRestored(win); + await restoring; + await restored; + + // The window has now been opened. Check the state that is returned, + // this should come from the cache while the window isn't restored, yet. + info("the window has been opened"); + checkWindows(); + + // The history has now been restored and the tabs are loading. The data must + // now come from the window, if it's correctly been marked as dirty before. + await new Promise(resolve => whenDelayedStartupFinished(win, resolve)); + info("the delayed startup has finished"); + checkWindows(); + + // Cleanup. + await BrowserTestUtils.closeWindow(win); + await promiseBrowserState(backupState); +}); + +function checkWindows() { + let state = JSON.parse(SessionStore.getBrowserState()); + is(state.windows[0].tabs.length, 2, "first window has two tabs"); + is(state.windows[1].tabs.length, 2, "second window has two tabs"); +} diff --git a/browser/components/sessionstore/test/browser_637020_slow.sjs b/browser/components/sessionstore/test/browser_637020_slow.sjs new file mode 100644 index 0000000000..abb1dee829 --- /dev/null +++ b/browser/components/sessionstore/test/browser_637020_slow.sjs @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const DELAY_MS = "2000"; + +let timer; + +function handleRequest(req, resp) { + resp.processAsync(); + resp.setHeader("Cache-Control", "no-cache", false); + resp.setHeader("Content-Type", "text/html;charset=utf-8", false); + + timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + resp.write("hi"); + resp.finish(); + }, + DELAY_MS, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/browser/components/sessionstore/test/browser_645428.js b/browser/components/sessionstore/test/browser_645428.js new file mode 100644 index 0000000000..4652df5d72 --- /dev/null +++ b/browser/components/sessionstore/test/browser_645428.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const NOTIFICATION = "sessionstore-browser-state-restored"; + +function test() { + waitForExplicitFinish(); + + function observe(subject, topic, data) { + if (NOTIFICATION == topic) { + finish(); + ok(true, "TOPIC received"); + } + } + + Services.obs.addObserver(observe, NOTIFICATION); + registerCleanupFunction(function() { + Services.obs.removeObserver(observe, NOTIFICATION); + }); + + ss.setBrowserState(JSON.stringify({ windows: [] })); +} diff --git a/browser/components/sessionstore/test/browser_659591.js b/browser/components/sessionstore/test/browser_659591.js new file mode 100644 index 0000000000..6cfcca1c8c --- /dev/null +++ b/browser/components/sessionstore/test/browser_659591.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() { + waitForExplicitFinish(); + + let eventReceived = false; + + registerCleanupFunction(function() { + ok(eventReceived, "SSWindowClosing event received"); + }); + + newWindow(function(win) { + win.addEventListener( + "SSWindowClosing", + function() { + eventReceived = true; + }, + { once: true } + ); + + BrowserTestUtils.closeWindow(win).then(() => { + waitForFocus(finish); + }); + }); +} + +function newWindow(callback) { + let opts = "chrome,all,dialog=no,height=800,width=800"; + let win = window.openDialog(AppConstants.BROWSER_CHROME_URL, "_blank", opts); + + win.addEventListener( + "load", + function() { + executeSoon(() => callback(win)); + }, + { once: true } + ); +} diff --git a/browser/components/sessionstore/test/browser_662743.js b/browser/components/sessionstore/test/browser_662743.js new file mode 100644 index 0000000000..f137a21ae6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_662743.js @@ -0,0 +1,138 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This tests that session restore component does restore the right <select> option. +// Session store should not rely only on previous user's selectedIndex, it should +// check its value as well. + +function test() { + /** Tests selected options **/ + requestLongerTimeout(2); + waitForExplicitFinish(); + + let testTabCount = 0; + let formData = [ + // default case + {}, + + // new format + // index doesn't match value (testing an option in between (two)) + { id: { select_id: { selectedIndex: 0, value: "val2" } } }, + // index doesn't match value (testing an invalid value) + { id: { select_id: { selectedIndex: 4, value: "val8" } } }, + // index doesn't match value (testing an invalid index) + { id: { select_id: { selectedIndex: 8, value: "val5" } } }, + // index and value match position zero + { id: { select_id: { selectedIndex: 0, value: "val0" } }, xpath: {} }, + // index doesn't match value (testing the last option (seven)) + { + id: {}, + xpath: { + "/xhtml:html/xhtml:body/xhtml:select[@name='select_name']": { + selectedIndex: 1, + value: "val7", + }, + }, + }, + // index and value match the default option "selectedIndex":3,"value":"val3" + { + xpath: { + "/xhtml:html/xhtml:body/xhtml:select[@name='select_name']": { + selectedIndex: 3, + value: "val3", + }, + }, + }, + // index matches default option however it doesn't match value + { id: { select_id: { selectedIndex: 3, value: "val4" } } }, + ]; + + let expectedValues = [ + null, // default value + "val2", + null, // default value (invalid value) + "val5", // value is still valid (even it has an invalid index) + "val0", + "val7", + null, + "val4", + ]; + let callback = function() { + testTabCount--; + if (testTabCount == 0) { + finish(); + } + }; + + for (let i = 0; i < formData.length; i++) { + testTabCount++; + testTabRestoreData(formData[i], expectedValues[i], callback); + } +} + +function testTabRestoreData(aFormData, aExpectedValue, aCallback) { + let testURL = getRootDirectory(gTestPath) + "browser_662743_sample.html"; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + + aFormData.url = testURL; + let tabState = { + entries: [{ url: testURL, triggeringPrincipal_base64 }], + formdata: aFormData, + }; + + promiseBrowserLoaded(tab.linkedBrowser).then(() => { + promiseTabState(tab, tabState) + .then(() => { + // Flush to make sure we have the latest form data. + return TabStateFlusher.flush(tab.linkedBrowser); + }) + .then(() => { + let doc = tab.linkedBrowser.contentDocument; + let select = doc.getElementById("select_id"); + let value = select.options[select.selectedIndex].value; + let restoredTabState = JSON.parse(ss.getTabState(tab)); + + // If aExpectedValue=null we don't expect any form data to be collected. + if (!aExpectedValue) { + ok( + !restoredTabState.hasOwnProperty("formdata"), + "no formdata collected" + ); + gBrowser.removeTab(tab); + aCallback(); + return; + } + + // test select options values + is( + value, + aExpectedValue, + "Select Option by selectedIndex &/or value has been restored correctly" + ); + + let restoredFormData = restoredTabState.formdata; + let selectIdFormData = restoredFormData.id.select_id; + value = restoredFormData.id.select_id.value; + + // test format + ok( + "id" in restoredFormData || "xpath" in restoredFormData, + "FormData format is valid" + ); + // test format + ok( + "selectedIndex" in selectIdFormData && "value" in selectIdFormData, + "select format is valid" + ); + // test set collection values + is(value, aExpectedValue, "Collection has been saved correctly"); + + // clean up + gBrowser.removeTab(tab); + + aCallback(); + }); + }); +} diff --git a/browser/components/sessionstore/test/browser_662743_sample.html b/browser/components/sessionstore/test/browser_662743_sample.html new file mode 100644 index 0000000000..0fd49417cc --- /dev/null +++ b/browser/components/sessionstore/test/browser_662743_sample.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<title>Test 662743</title> + +<!-- Select events --> +<h3>Select options</h3> +<select id="select_id" name="select_name"> + <option value="val0">Zero</option> + <option value="val1">One</option> + <option value="val2">Two</option> + <option value="val3" selected="selected">Three</option> + <option value="val4">Four</option> + <option value="val5">Five</option> + <option value="val6">Six</option> + <option value="val7">Seven</option> +</select> diff --git a/browser/components/sessionstore/test/browser_662812.js b/browser/components/sessionstore/test/browser_662812.js new file mode 100644 index 0000000000..9a51453432 --- /dev/null +++ b/browser/components/sessionstore/test/browser_662812.js @@ -0,0 +1,43 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() { + waitForExplicitFinish(); + + window.addEventListener( + "SSWindowStateBusy", + function() { + let state = ss.getWindowState(window); + ok(state.windows[0].busy, "window is busy"); + + window.addEventListener( + "SSWindowStateReady", + function() { + let state2 = ss.getWindowState(window); + ok(!state2.windows[0].busy, "window is not busy"); + + executeSoon(() => { + gBrowser.removeTab(gBrowser.tabs[1]); + finish(); + }); + }, + { once: true } + ); + }, + { once: true } + ); + + // create a new tab + let tab = BrowserTestUtils.addTab(gBrowser, "about:mozilla"); + let browser = tab.linkedBrowser; + + // close and restore it + browser.addEventListener( + "load", + function() { + gBrowser.removeTab(tab); + ss.undoCloseTab(window, 0); + }, + { capture: true, once: true } + ); +} diff --git a/browser/components/sessionstore/test/browser_665702-state_session.js b/browser/components/sessionstore/test/browser_665702-state_session.js new file mode 100644 index 0000000000..fc024e2247 --- /dev/null +++ b/browser/components/sessionstore/test/browser_665702-state_session.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function compareArray(a, b) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + +function test() { + let currentState = JSON.parse(ss.getBrowserState()); + ok(currentState.session, "session data returned by getBrowserState"); + + let keys = Object.keys(currentState.session); + let expectedKeys = ["lastUpdate", "startTime", "recentCrashes"]; + ok( + compareArray(keys.sort(), expectedKeys.sort()), + "session object from getBrowserState has correct keys" + ); +} diff --git a/browser/components/sessionstore/test/browser_682507.js b/browser/components/sessionstore/test/browser_682507.js new file mode 100644 index 0000000000..00a8ad4cd5 --- /dev/null +++ b/browser/components/sessionstore/test/browser_682507.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function test() { + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + BrowserTestUtils.addTab(gBrowser, "about:mozilla"); + + ss.setTabState(gBrowser.tabs[1], ss.getTabState(gBrowser.tabs[1])); + ok( + gBrowser.tabs[1].hasAttribute("pending"), + "second tab should have 'pending' attribute" + ); + + gBrowser.selectedTab = gBrowser.tabs[1]; + ok( + !gBrowser.tabs[1].hasAttribute("pending"), + "second tab should have not 'pending' attribute" + ); + + gBrowser.removeTab(gBrowser.tabs[1]); + Services.prefs.clearUserPref("browser.sessionstore.restore_on_demand"); +} diff --git a/browser/components/sessionstore/test/browser_687710.js b/browser/components/sessionstore/test/browser_687710.js new file mode 100644 index 0000000000..e3e04419d7 --- /dev/null +++ b/browser/components/sessionstore/test/browser_687710.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that sessionrestore handles cycles in the shentry graph properly. +// +// These cycles shouldn't be there in the first place, but they cause hangs +// when they mysteriously appear (bug 687710). Docshell code assumes this +// graph is a tree and tires to walk to the root. But if there's a cycle, +// there is no root, and we loop forever. + +var stateBackup = ss.getBrowserState(); + +var state = { + windows: [ + { + tabs: [ + { + entries: [ + { + docIdentifier: 1, + url: "http://example.com", + triggeringPrincipal_base64, + children: [ + { + docIdentifier: 2, + url: "http://example.com", + triggeringPrincipal_base64, + }, + ], + }, + { + docIdentifier: 2, + url: "http://example.com", + triggeringPrincipal_base64, + children: [ + { + docIdentifier: 1, + url: "http://example.com", + triggeringPrincipal_base64, + }, + ], + }, + ], + }, + ], + }, + ], +}; + +add_task(async function test() { + registerCleanupFunction(function() { + ss.setBrowserState(stateBackup); + }); + + /* This test fails by hanging. */ + await setBrowserState(state); + ok(true, "Didn't hang!"); +}); diff --git a/browser/components/sessionstore/test/browser_687710_2.js b/browser/components/sessionstore/test/browser_687710_2.js new file mode 100644 index 0000000000..e54cfd4ddf --- /dev/null +++ b/browser/components/sessionstore/test/browser_687710_2.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that the fix for bug 687710 isn't too aggressive -- shentries which are +// cousins should be able to share bfcache entries. + +var stateBackup = ss.getBrowserState(); + +var state = { + entries: [ + { + docIdentifier: 1, + url: "http://example.com?1", + triggeringPrincipal_base64, + children: [ + { + docIdentifier: 10, + url: "http://example.com?10", + triggeringPrincipal_base64, + }, + ], + }, + { + docIdentifier: 1, + url: "http://example.com?1#a", + triggeringPrincipal_base64, + children: [ + { + docIdentifier: 10, + url: "http://example.com?10#aa", + triggeringPrincipal_base64, + }, + ], + }, + ], +}; + +add_task(async function test() { + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + await promiseTabState(tab, state); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(tab.linkedBrowser, [], function() { + function compareEntries(i, j, history) { + let e1 = history.getEntryAtIndex(i); + let e2 = history.getEntryAtIndex(j); + + ok(e1.sharesDocumentWith(e2), `${i} should share doc with ${j}`); + is(e1.childCount, e2.childCount, `Child count mismatch (${i}, ${j})`); + + for (let c = 0; c < e1.childCount; c++) { + let c1 = e1.GetChildAt(c); + let c2 = e2.GetChildAt(c); + + ok( + c1.sharesDocumentWith(c2), + `Cousins should share documents. (${i}, ${j}, ${c})` + ); + } + } + + let history = docShell.browsingContext.childSessionHistory.legacySHistory; + + is(history.count, 2, "history.count"); + for (let i = 0; i < history.count; i++) { + for (let j = 0; j < history.count; j++) { + compareEntries(i, j, history); + } + } + }); + } else { + function compareEntries(i, j, history) { + let e1 = history.getEntryAtIndex(i); + let e2 = history.getEntryAtIndex(j); + + ok(e1.sharesDocumentWith(e2), `${i} should share doc with ${j}`); + is(e1.childCount, e2.childCount, `Child count mismatch (${i}, ${j})`); + + for (let c = 0; c < e1.childCount; c++) { + let c1 = e1.GetChildAt(c); + let c2 = e2.GetChildAt(c); + + ok( + c1.sharesDocumentWith(c2), + `Cousins should share documents. (${i}, ${j}, ${c})` + ); + } + } + + let history = tab.linkedBrowser.browsingContext.sessionHistory; + + is(history.count, 2, "history.count"); + for (let i = 0; i < history.count; i++) { + for (let j = 0; j < history.count; j++) { + compareEntries(i, j, history); + } + } + } + + ss.setBrowserState(stateBackup); +}); diff --git a/browser/components/sessionstore/test/browser_694378.js b/browser/components/sessionstore/test/browser_694378.js new file mode 100644 index 0000000000..fc6619b9ab --- /dev/null +++ b/browser/components/sessionstore/test/browser_694378.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test Summary: +// 1. call ss.setWindowState with a broken state +// 1a. ensure that it doesn't throw. + +add_task(async function test_brokenWindowState() { + let brokenState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }] }, + ], + }, + ], + selectedWindow: 2, + }; + + let gotError = false; + try { + await setWindowState(window, brokenState, true); + } catch (ex) { + gotError = true; + info(ex); + } + + ok(!gotError, "ss.setWindowState did not throw an error"); + + // Make sure that we reset the state. Use a full state just in case things get crazy. + let blankState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + }; + await promiseBrowserState(blankState); +}); diff --git a/browser/components/sessionstore/test/browser_701377.js b/browser/components/sessionstore/test/browser_701377.js new file mode 100644 index 0000000000..f8e54786a6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_701377.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.com#1", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.com#2", triggeringPrincipal_base64 }, + ], + hidden: true, + }, + ], + }, + ], +}; + +function test() { + waitForExplicitFinish(); + + newWindowWithState(state, function(aWindow) { + let tab = aWindow.gBrowser.tabs[1]; + ok(tab.hidden, "the second tab is hidden"); + + let tabShown = false; + let tabShowCallback = () => (tabShown = true); + tab.addEventListener("TabShow", tabShowCallback); + + let tabState = ss.getTabState(tab); + ss.setTabState(tab, tabState); + + tab.removeEventListener("TabShow", tabShowCallback); + ok(tab.hidden && !tabShown, "tab remains hidden"); + + finish(); + }); +} + +// ---------- +function newWindowWithState(aState, aCallback) { + let opts = "chrome,all,dialog=no,height=800,width=800"; + let win = window.openDialog(AppConstants.BROWSER_CHROME_URL, "_blank", opts); + + registerCleanupFunction(() => BrowserTestUtils.closeWindow(win)); + + whenWindowLoaded(win, function onWindowLoaded(aWin) { + ss.setWindowState(aWin, JSON.stringify(aState), true); + executeSoon(() => aCallback(aWin)); + }); +} diff --git a/browser/components/sessionstore/test/browser_705597.js b/browser/components/sessionstore/test/browser_705597.js new file mode 100644 index 0000000000..e41b07b3ea --- /dev/null +++ b/browser/components/sessionstore/test/browser_705597.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +var tabState = { + entries: [ + { + url: "about:robots", + triggeringPrincipal_base64, + children: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + }, + ], +}; + +function test() { + waitForExplicitFinish(); + requestLongerTimeout(2); + + Services.prefs.setIntPref("browser.sessionstore.interval", 4000); + registerCleanupFunction(function() { + Services.prefs.clearUserPref("browser.sessionstore.interval"); + }); + + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + + let browser = tab.linkedBrowser; + + promiseTabState(tab, tabState).then(() => { + let entry; + if (!Services.appinfo.sessionHistoryInParent) { + let sessionHistory = browser.sessionHistory; + entry = sessionHistory.legacySHistory.getEntryAtIndex(0); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + entry = sessionHistory.getEntryAtIndex(0); + } + + whenChildCount(entry, 1, function() { + whenChildCount(entry, 2, function() { + promiseBrowserLoaded(browser) + .then(() => { + return TabStateFlusher.flush(browser); + }) + .then(() => { + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "tab has one history entry"); + ok(!entries[0].children, "history entry has no subframes"); + + // Make sure that we reset the state. + let blankState = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "about:blank", triggeringPrincipal_base64 }, + ], + }, + ], + }, + ], + }; + waitForBrowserState(blankState, finish); + }); + + // Force reload the browser to deprecate the subframes. + browser.reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE); + }); + + // Create a dynamic subframe. + let doc = browser.contentDocument; + let iframe = doc.createElement("iframe"); + doc.body.appendChild(iframe); + iframe.setAttribute("src", "about:mozilla"); + }); + }); +} + +function whenChildCount(aEntry, aChildCount, aCallback) { + if (aEntry.childCount == aChildCount) { + aCallback(); + } else { + setTimeout(() => whenChildCount(aEntry, aChildCount, aCallback), 100); + } +} diff --git a/browser/components/sessionstore/test/browser_707862.js b/browser/components/sessionstore/test/browser_707862.js new file mode 100644 index 0000000000..de1d9ea7c7 --- /dev/null +++ b/browser/components/sessionstore/test/browser_707862.js @@ -0,0 +1,95 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +var tabState = { + entries: [ + { + url: "about:robots", + triggeringPrincipal_base64, + children: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + }, + ], +}; + +function test() { + waitForExplicitFinish(); + requestLongerTimeout(2); + + Services.prefs.setIntPref("browser.sessionstore.interval", 4000); + registerCleanupFunction(function() { + Services.prefs.clearUserPref("browser.sessionstore.interval"); + }); + + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + + let browser = tab.linkedBrowser; + + promiseTabState(tab, tabState).then(() => { + let entry; + if (!Services.appinfo.sessionHistoryInParent) { + let sessionHistory = browser.sessionHistory; + entry = sessionHistory.legacySHistory.getEntryAtIndex(0); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + entry = sessionHistory.getEntryAtIndex(0); + } + + whenChildCount(entry, 1, function() { + whenChildCount(entry, 2, function() { + promiseBrowserLoaded(browser).then(() => { + let newEntry; + if (!Services.appinfo.sessionHistoryInParent) { + let newSessionHistory = browser.sessionHistory; + newEntry = newSessionHistory.legacySHistory.getEntryAtIndex(0); + } else { + let newSessionHistory = browser.browsingContext.sessionHistory; + newEntry = newSessionHistory.getEntryAtIndex(0); + } + + whenChildCount(newEntry, 0, function() { + // Make sure that we reset the state. + let blankState = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "about:blank", triggeringPrincipal_base64 }, + ], + }, + ], + }, + ], + }; + waitForBrowserState(blankState, finish); + }); + }); + + // Force reload the browser to deprecate the subframes. + browser.reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE); + }); + + // Create a dynamic subframe. + let doc = browser.contentDocument; + let iframe = doc.createElement("iframe"); + doc.body.appendChild(iframe); + iframe.setAttribute("src", "about:mozilla"); + }); + }); + + // This test relies on the test timing out in order to indicate failure so + // let's add a dummy pass. + ok( + true, + "Each test requires at least one pass, fail or todo so here is a pass." + ); +} + +function whenChildCount(aEntry, aChildCount, aCallback) { + if (aEntry.childCount == aChildCount) { + aCallback(); + } else { + setTimeout(() => whenChildCount(aEntry, aChildCount, aCallback), 100); + } +} diff --git a/browser/components/sessionstore/test/browser_739531.js b/browser/components/sessionstore/test/browser_739531.js new file mode 100644 index 0000000000..da82c45991 --- /dev/null +++ b/browser/components/sessionstore/test/browser_739531.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test ensures that attempts made to save/restore ("duplicate") pages +// using designmode AND make changes to document structure (remove body) +// don't result in uncaught errors and a broken browser state. + +function test() { + waitForExplicitFinish(); + + let testURL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_739531_sample.html"; + + let loadCount = 0; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + + let removeFunc; + removeFunc = BrowserTestUtils.addContentEventListener( + tab.linkedBrowser, + "load", + function onLoad(aEvent) { + // make sure both the page and the frame are loaded + if (++loadCount < 2) { + return; + } + removeFunc(); + + // executeSoon to allow the JS to execute on the page + executeSoon(function() { + let tab2; + let caughtError = false; + try { + tab2 = ss.duplicateTab(window, tab); + } catch (e) { + caughtError = true; + info(e); + } + + is(gBrowser.tabs.length, 3, "there should be 3 tabs"); + + ok(!caughtError, "duplicateTab didn't throw"); + + // if the test fails, we don't want to try to close a tab that doesn't exist + if (tab2) { + gBrowser.removeTab(tab2); + } + gBrowser.removeTab(tab); + + finish(); + }); + }, + true + ); +} diff --git a/browser/components/sessionstore/test/browser_739531_frame.html b/browser/components/sessionstore/test/browser_739531_frame.html new file mode 100644 index 0000000000..10f045a394 --- /dev/null +++ b/browser/components/sessionstore/test/browser_739531_frame.html @@ -0,0 +1 @@ +<body><html>1</html></body> diff --git a/browser/components/sessionstore/test/browser_739531_sample.html b/browser/components/sessionstore/test/browser_739531_sample.html new file mode 100644 index 0000000000..e6c48ff26c --- /dev/null +++ b/browser/components/sessionstore/test/browser_739531_sample.html @@ -0,0 +1,23 @@ +<!-- originally a crash test for bug 713417 + https://bug713417.bugzilla.mozilla.org/attachment.cgi?id=584240 --> +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +<script> + +function boom() { + var w = document.getElementById("f").contentWindow; + var d = w.document; + d.designMode = "on"; + var r = d.documentElement; + d.removeChild(r); + document.adoptNode(r); +} + +</script> +</head> +<body onload="boom();"> +<iframe src="browser_739531_frame.html" id="f"></iframe> +</body> +</html> diff --git a/browser/components/sessionstore/test/browser_739805.js b/browser/components/sessionstore/test/browser_739805.js new file mode 100644 index 0000000000..66194748a8 --- /dev/null +++ b/browser/components/sessionstore/test/browser_739805.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +var url = "data:text/html;charset=utf-8,<input%20id='foo'>"; +var tabState = { + entries: [{ url, triggeringPrincipal_base64 }], + formdata: { id: { foo: "bar" }, url }, +}; + +function test() { + waitForExplicitFinish(); + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + + registerCleanupFunction(function() { + if (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.tabs[1]); + } + Services.prefs.clearUserPref("browser.sessionstore.restore_on_demand"); + }); + + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + + promiseBrowserLoaded(browser).then(() => { + isnot(gBrowser.selectedTab, tab, "newly created tab is not selected"); + + ss.setTabState(tab, JSON.stringify(tabState)); + is( + ss.getInternalObjectState(browser), + TAB_STATE_NEEDS_RESTORE, + "tab needs restoring" + ); + + let { formdata } = JSON.parse(ss.getTabState(tab)); + is(formdata && formdata.id.foo, "bar", "tab state's formdata is valid"); + + promiseTabRestored(tab).then(() => { + SpecialPowers.spawn(browser, [], function() { + let input = content.document.getElementById("foo"); + is(input.value, "bar", "formdata has been restored correctly"); + }).then(() => { + finish(); + }); + }); + + // Restore the tab by selecting it. + gBrowser.selectedTab = tab; + }); +} diff --git a/browser/components/sessionstore/test/browser_819510_perwindowpb.js b/browser/components/sessionstore/test/browser_819510_perwindowpb.js new file mode 100644 index 0000000000..8dd41ef055 --- /dev/null +++ b/browser/components/sessionstore/test/browser_819510_perwindowpb.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test opening default mochitest-normal-private-normal-private windows +// (saving the state with last window being private) + +requestLongerTimeout(2); + +add_task(async function test_1() { + let win = await promiseNewWindowLoaded(); + await promiseTabLoad(win, "http://www.example.com/#1"); + + win = await promiseNewWindowLoaded({ private: true }); + await promiseTabLoad(win, "http://www.example.com/#2"); + + win = await promiseNewWindowLoaded(); + await promiseTabLoad(win, "http://www.example.com/#3"); + + win = await promiseNewWindowLoaded({ private: true }); + await promiseTabLoad(win, "http://www.example.com/#4"); + + let curState = JSON.parse(ss.getBrowserState()); + is(curState.windows.length, 5, "Browser has opened 5 windows"); + is(curState.windows[2].isPrivate, true, "Window is private"); + is(curState.windows[4].isPrivate, true, "Last window is private"); + is(curState.selectedWindow, 5, "Last window opened is the one selected"); + + let state = JSON.parse(await promiseRecoveryFileContents()); + + is( + state.windows.length, + 2, + "sessionstore state: 2 windows in data being written to disk" + ); + is( + state.selectedWindow, + 2, + "Selected window is updated to match one of the saved windows" + ); + ok( + state.windows.every(win2 => !win2.isPrivate), + "Saved windows are not private" + ); + is( + state._closedWindows.length, + 0, + "sessionstore state: no closed windows in data being written to disk" + ); + + // Cleanup. + await promiseAllButPrimaryWindowClosed(); + forgetClosedWindows(); +}); + +// Test opening default mochitest window + 2 private windows +add_task(async function test_2() { + let win = await promiseNewWindowLoaded({ private: true }); + await promiseTabLoad(win, "http://www.example.com/#1"); + + win = await promiseNewWindowLoaded({ private: true }); + await promiseTabLoad(win, "http://www.example.com/#2"); + + let curState = JSON.parse(ss.getBrowserState()); + is(curState.windows.length, 3, "Browser has opened 3 windows"); + is(curState.windows[1].isPrivate, true, "Window 1 is private"); + is(curState.windows[2].isPrivate, true, "Window 2 is private"); + is(curState.selectedWindow, 3, "Last window opened is the one selected"); + + let state = JSON.parse(await promiseRecoveryFileContents()); + + is( + state.windows.length, + 0, + "sessionstore state: no window in data being written to disk" + ); + is( + state.selectedWindow, + 0, + "Selected window updated to 0 given there are no saved windows" + ); + is( + state._closedWindows.length, + 0, + "sessionstore state: no closed windows in data being written to disk" + ); + + // Cleanup. + await promiseAllButPrimaryWindowClosed(); + forgetClosedWindows(); +}); + +// Test opening default-normal-private-normal windows and closing a normal window +add_task(async function test_3() { + let normalWindow = await promiseNewWindowLoaded(); + await promiseTabLoad(normalWindow, "http://www.example.com/#1"); + + let win = await promiseNewWindowLoaded({ private: true }); + await promiseTabLoad(win, "http://www.example.com/#2"); + + win = await promiseNewWindowLoaded(); + await promiseTabLoad(win, "http://www.example.com/#3"); + + let curState = JSON.parse(ss.getBrowserState()); + is(curState.windows.length, 4, "Browser has opened 4 windows"); + is(curState.windows[2].isPrivate, true, "Window 2 is private"); + is(curState.selectedWindow, 4, "Last window opened is the one selected"); + + await BrowserTestUtils.closeWindow(normalWindow); + + // Pin and unpin a tab before checking the written state so that + // the list of restoring windows gets cleared. Otherwise the + // window we just closed would be marked as not closed. + let tab = win.gBrowser.tabs[0]; + win.gBrowser.pinTab(tab); + win.gBrowser.unpinTab(tab); + + let state = JSON.parse(await promiseRecoveryFileContents()); + + is( + state.windows.length, + 1, + "sessionstore state: 1 window in data being written to disk" + ); + is( + state.selectedWindow, + 1, + "Selected window is updated to match one of the saved windows" + ); + ok( + state.windows.every(win2 => !win2.isPrivate), + "Saved windows are not private" + ); + is( + state._closedWindows.length, + 1, + "sessionstore state: 1 closed window in data being written to disk" + ); + ok( + state._closedWindows.every(win2 => !win2.isPrivate), + "Closed windows are not private" + ); + + // Cleanup. + await promiseAllButPrimaryWindowClosed(); + forgetClosedWindows(); +}); + +async function promiseTabLoad(win, url) { + let tab = BrowserTestUtils.addTab(win.gBrowser, url); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); +} diff --git a/browser/components/sessionstore/test/browser_906076_lazy_tabs.js b/browser/components/sessionstore/test/browser_906076_lazy_tabs.js new file mode 100644 index 0000000000..2fab82ca01 --- /dev/null +++ b/browser/components/sessionstore/test/browser_906076_lazy_tabs.js @@ -0,0 +1,163 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_STATE = { + windows: [ + { + tabs: [ + { + entries: [{ url: "http://example.com", triggeringPrincipal_base64 }], + }, + { + entries: [{ url: "http://example.com", triggeringPrincipal_base64 }], + }, + { + entries: [{ url: "http://example.com", triggeringPrincipal_base64 }], + }, + { + entries: [{ url: "http://example.com", triggeringPrincipal_base64 }], + }, + { + entries: [{ url: "http://example.com", triggeringPrincipal_base64 }], + }, + { + entries: [{ url: "http://example.com", triggeringPrincipal_base64 }], + }, + { + entries: [{ url: "http://example.com", triggeringPrincipal_base64 }], + }, + { + entries: [{ url: "http://example.com", triggeringPrincipal_base64 }], + }, + { + entries: [{ url: "http://example.com", triggeringPrincipal_base64 }], + }, + { + entries: [{ url: "http://example.com", triggeringPrincipal_base64 }], + }, + ], + }, + ], +}; + +const TEST_STATE_2 = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:robots", triggeringPrincipal_base64 }] }, + { + entries: [], + userTypedValue: "http://example.com", + userTypedClear: 1, + }, + ], + }, + ], +}; + +function countNonLazyTabs(win) { + win = win || window; + let count = 0; + for (let browser of win.gBrowser.browsers) { + if (browser.isConnected) { + count++; + } + } + return count; +} + +/** + * Test that lazy browsers do not get prematurely inserted by + * code accessing browser bound properties on the unbound browser. + */ + +add_task(async function test() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.sessionstore.restore_on_demand", true], + ["browser.sessionstore.restore_tabs_lazily", true], + ], + }); + + let backupState = SessionStore.getBrowserState(); + + await promiseBrowserState(TEST_STATE); + + info( + "Check that no lazy browsers get unnecessarily inserted after session restore" + ); + is(countNonLazyTabs(), 1, "Window has only 1 non-lazy tab"); + + await TestUtils.topicObserved("sessionstore-state-write-complete"); + + // When sessionstore write occurs, tabs are checked for state changes. + // Make sure none of them insert their browsers when this happens. + info("Check that no lazy browsers get inserted after sessionstore write"); + is(countNonLazyTabs(), 1, "Window has only 1 non-lazy tab"); + + info("Check that lazy browser gets inserted properly"); + ok( + !gBrowser.browsers[1].isConnected, + "The browser that we're attempting to insert is indeed lazy" + ); + gBrowser._insertBrowser(gBrowser.tabs[1]); + is(countNonLazyTabs(), 2, "Window now has 2 non-lazy tabs"); + + // Check if any lazy tabs got inserted when window closes. + let newWindow = await promiseNewWindowLoaded(); + + SessionStore.setWindowState(newWindow, JSON.stringify(TEST_STATE)); + + await new Promise(resolve => { + newWindow.addEventListener( + "unload", + () => { + info("Check that no lazy browsers get inserted when window closes"); + is(countNonLazyTabs(newWindow), 1, "Window has only 1 non-lazy tab"); + + info( + "Check that it is not possible to insert a lazy browser after the window closed" + ); + ok( + !newWindow.gBrowser.browsers[1].isConnected, + "The browser that we're attempting to insert is indeed lazy" + ); + newWindow.gBrowser._insertBrowser(newWindow.gBrowser.tabs[1]); + is( + countNonLazyTabs(newWindow), + 1, + "Window still has only 1 non-lazy tab" + ); + + resolve(); + }, + { once: true } + ); + + newWindow.close(); + }); + + // Bug 1365933. + info( + "Check that session with tab having empty entries array gets restored properly" + ); + await promiseBrowserState(TEST_STATE_2); + + is(gBrowser.tabs.length, 2, "Window has 2 tabs"); + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:robots", + "Tab has the expected URL" + ); + + gBrowser.selectedTab = gBrowser.tabs[1]; + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + is( + gBrowser.selectedBrowser.currentURI.spec, + "http://example.com/", + "Tab has the expected URL" + ); + + // Cleanup. + await promiseBrowserState(backupState); +}); diff --git a/browser/components/sessionstore/test/browser_911547.js b/browser/components/sessionstore/test/browser_911547.js new file mode 100644 index 0000000000..14b9796606 --- /dev/null +++ b/browser/components/sessionstore/test/browser_911547.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test tests that session restore component does restore the right +// content security policy with the document. (The policy being tested +// disallows inline scripts). + +add_task(async function test() { + // allow top level data: URI navigations, otherwise clicking a data: link fails + await SpecialPowers.pushPrefEnv({ + set: [["security.data_uri.block_toplevel_data_uri_navigations", false]], + }); + // create a tab that has a CSP + let testURL = + "http://mochi.test:8888/browser/browser/components/sessionstore/test/browser_911547_sample.html"; + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, testURL)); + gBrowser.selectedTab = tab; + + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // this is a baseline to ensure CSP is active + // attempt to inject and run a script via inline (pre-restore, allowed) + await injectInlineScript( + browser, + `document.getElementById("test_id1").value = "id1_modified";` + ); + + let loadedPromise = promiseBrowserLoaded(browser); + await SpecialPowers.spawn(browser, [], function() { + is( + content.document.getElementById("test_id1").value, + "id1_initial", + "CSP should block the inline script that modifies test_id" + ); + content.document.getElementById("test_data_link").click(); + }); + + await loadedPromise; + + await SpecialPowers.spawn(browser, [], function() { + // eslint-disable-line + // the data: URI inherits the CSP and the inline script needs to be blocked + is( + content.document.getElementById("test_id2").value, + "id2_initial", + "CSP should block the script loaded by the clicked data URI" + ); + }); + + // close the tab + await promiseRemoveTabAndSessionState(tab); + + // open new tab and recover the state + tab = ss.undoCloseTab(window, 0); + await promiseTabRestored(tab); + browser = tab.linkedBrowser; + + await SpecialPowers.spawn(browser, [], function() { + // eslint-disable-line + // the data: URI should be restored including the inherited CSP and the + // inline script should be blocked. + is( + content.document.getElementById("test_id2").value, + "id2_initial", + "CSP should block the script loaded by the clicked data URI after restore" + ); + }); + + // clean up + gBrowser.removeTab(tab); +}); + +// injects an inline script element (with a text body) +function injectInlineScript(browser, scriptText) { + return SpecialPowers.spawn(browser, [scriptText], function(text) { + let scriptElt = content.document.createElement("script"); + scriptElt.type = "text/javascript"; + scriptElt.text = text; + content.document.body.appendChild(scriptElt); + }); +} diff --git a/browser/components/sessionstore/test/browser_911547_sample.html b/browser/components/sessionstore/test/browser_911547_sample.html new file mode 100644 index 0000000000..9d2706c008 --- /dev/null +++ b/browser/components/sessionstore/test/browser_911547_sample.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>Test 911547</title> + </head> +<body> + + <!-- + this element gets modified by an injected script; + that script should be blocked by CSP + --> + <input type="text" id="test_id1" value="id1_initial"> + + <a id="test_data_link" href="data:text/html;charset=utf-8,<input type='text' id='test_id2' value='id2_initial'/> <script>document.getElementById('test_id2').value = 'id2_modified';</script>">Test Link</a> + +</body> +</html> diff --git a/browser/components/sessionstore/test/browser_911547_sample.html^headers^ b/browser/components/sessionstore/test/browser_911547_sample.html^headers^ new file mode 100644 index 0000000000..4623dec303 --- /dev/null +++ b/browser/components/sessionstore/test/browser_911547_sample.html^headers^ @@ -0,0 +1 @@ +Content-Security-Policy: script-src 'self' diff --git a/browser/components/sessionstore/test/browser_aboutPrivateBrowsing.js b/browser/components/sessionstore/test/browser_aboutPrivateBrowsing.js new file mode 100644 index 0000000000..a2306df4a5 --- /dev/null +++ b/browser/components/sessionstore/test/browser_aboutPrivateBrowsing.js @@ -0,0 +1,24 @@ +"use strict"; + +// Tests that an about:privatebrowsing tab with no history will not +// be saved into session store and thus, it will not show up in +// Recently Closed Tabs. + +add_task(async function() { + let tab = BrowserTestUtils.addTab(gBrowser, "about:privatebrowsing"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + is( + gBrowser.browsers[1].currentURI.spec, + "about:privatebrowsing", + "we will be removing an about:privatebrowsing tab" + ); + + let r = `rand-${Math.random()}`; + ss.setCustomTabValue(tab, "foobar", r); + + await promiseRemoveTabAndSessionState(tab); + let closedTabData = JSON.stringify(ss.getClosedTabData(window)); + ok(!closedTabData.includes(r), "tab not stored in _closedTabs"); +}); diff --git a/browser/components/sessionstore/test/browser_aboutSessionRestore.js b/browser/components/sessionstore/test/browser_aboutSessionRestore.js new file mode 100644 index 0000000000..90b7705985 --- /dev/null +++ b/browser/components/sessionstore/test/browser_aboutSessionRestore.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const CRASH_URL = "about:mozilla"; +const CRASH_FAVICON = "chrome://branding/content/icon32.png"; +const CRASH_SHENTRY = { url: CRASH_URL, triggeringPrincipal_base64 }; +const CRASH_TAB = { entries: [CRASH_SHENTRY], image: CRASH_FAVICON }; +const CRASH_STATE = { windows: [{ tabs: [CRASH_TAB] }] }; + +const TAB_URL = "about:sessionrestore"; +const TAB_FORMDATA = { url: TAB_URL, id: { sessionData: CRASH_STATE } }; +const TAB_SHENTRY = { url: TAB_URL, triggeringPrincipal_base64 }; +const TAB_STATE = { entries: [TAB_SHENTRY], formdata: TAB_FORMDATA }; + +add_task(async function() { + // Prepare a blank tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Fake a post-crash tab. + ss.setTabState(tab, JSON.stringify(TAB_STATE)); + await promiseTabRestored(tab); + + ok(gBrowser.tabs.length > 1, "we have more than one tab"); + + let tabsToggle = browser.contentDocument.getElementById("tabsToggle"); + tabsToggle.click(); + await BrowserTestUtils.waitForCondition( + () => browser.contentWindow.gTreeInitialized + ); + let tree = browser.contentDocument.getElementById("tabList"); + let view = tree.view; + ok(view.isContainer(0), "first entry is the window"); + let titleColumn = tree.columns.title; + is( + view.getCellProperties(1, titleColumn), + "icon", + "second entry is the tab and has a favicon" + ); + + let newWindowOpened = BrowserTestUtils.waitForNewWindow(); + + SpecialPowers.spawn(browser.browsingContext, [], () => { + content.document.getElementById("errorTryAgain").click(); + }); + + // Wait until the new window was restored. + let win = await newWindowOpened; + + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(win); + + let [ + { + tabs: [ + { + entries: [{ url }], + }, + ], + }, + ] = ss.getClosedWindowData(); + is(url, CRASH_URL, "session was restored correctly"); + ss.forgetClosedWindow(0); +}); diff --git a/browser/components/sessionstore/test/browser_async_duplicate_tab.js b/browser/components/sessionstore/test/browser_async_duplicate_tab.js new file mode 100644 index 0000000000..3cf041ced5 --- /dev/null +++ b/browser/components/sessionstore/test/browser_async_duplicate_tab.js @@ -0,0 +1,87 @@ +"use strict"; + +const PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content/", + "http://example.com/" +); +const URL = PATH + "file_async_duplicate_tab.html"; + +add_task(async function test_duplicate() { + // Create new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to empty any queued update messages. + await TabStateFlusher.flush(browser); + + // Click the link to navigate, this will add second shistory entry. + await SpecialPowers.spawn(browser, [], async function() { + return new Promise(resolve => { + docShell.chromeEventHandler.addEventListener( + "hashchange", + () => resolve(), + { once: true, capture: true } + ); + + // Click the link. + content.document.querySelector("a").click(); + }); + }); + + // Duplicate the tab. + let tab2 = ss.duplicateTab(window, tab); + + // Wait until the tab has fully restored. + await promiseTabRestored(tab2); + await TabStateFlusher.flush(tab2.linkedBrowser); + + // There should be two history entries now. + let { entries } = JSON.parse(ss.getTabState(tab2)); + is(entries.length, 2, "there are two shistory entries"); + + // Cleanup. + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_duplicate_remove() { + // Create new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to empty any queued update messages. + await TabStateFlusher.flush(browser); + + // Click the link to navigate, this will add second shistory entry. + await SpecialPowers.spawn(browser, [], async function() { + return new Promise(resolve => { + docShell.chromeEventHandler.addEventListener( + "hashchange", + () => resolve(), + { once: true, capture: true } + ); + + // Click the link. + content.document.querySelector("a").click(); + }); + }); + + // Duplicate the tab. + let tab2 = ss.duplicateTab(window, tab); + + // Before the duplication finished, remove the tab. + await Promise.all([ + promiseRemoveTabAndSessionState(tab), + promiseTabRestored(tab2), + ]); + await TabStateFlusher.flush(tab2.linkedBrowser); + + // There should be two history entries now. + let { entries } = JSON.parse(ss.getTabState(tab2)); + is(entries.length, 2, "there are two shistory entries"); + + // Cleanup. + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/sessionstore/test/browser_async_flushes.js b/browser/components/sessionstore/test/browser_async_flushes.js new file mode 100644 index 0000000000..dc6bfa4bc4 --- /dev/null +++ b/browser/components/sessionstore/test/browser_async_flushes.js @@ -0,0 +1,131 @@ +"use strict"; + +const PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content/", + "http://example.com/" +); +const URL = PATH + "file_async_flushes.html"; + +add_task(async function test_flush() { + // Create new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to empty any queued update messages. + await TabStateFlusher.flush(browser); + + // There should be one history entry. + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is a single history entry"); + + // Click the link to navigate, this will add second shistory entry. + await SpecialPowers.spawn(browser, [], async function() { + return new Promise(resolve => { + docShell.chromeEventHandler.addEventListener( + "hashchange", + () => resolve(), + { once: true, capture: true } + ); + + // Click the link. + content.document.querySelector("a").click(); + }); + }); + + // Flush to empty any queued update messages. + await TabStateFlusher.flush(browser); + + // There should be two history entries now. + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there are two shistory entries"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +add_task(async function test_crash() { + if (Services.appinfo.sessionHistoryInParent) { + // This test relies on frame script message ordering. Since the frame script + // is unused with SHIP, there's no guarantee that we'll crash the frame + // before we've started the flush. + ok(true, "Test relies on frame script message ordering."); + return; + } + + // Create new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + gBrowser.selectedTab = tab; + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to empty any queued update messages. + await TabStateFlusher.flush(browser); + + // There should be one history entry. + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is a single history entry"); + + // Click the link to navigate. + await SpecialPowers.spawn(browser, [], async function() { + return new Promise(resolve => { + docShell.chromeEventHandler.addEventListener( + "hashchange", + () => resolve(), + { once: true, capture: true } + ); + + // Click the link. + content.document.querySelector("a").click(); + }); + }); + + // Crash the browser and flush. Both messages are async and will be sent to + // the content process. The "crash" message makes it first so that we don't + // get a chance to process the flush. The TabStateFlusher however should be + // notified so that the flush still completes. + let promise1 = BrowserTestUtils.crashFrame(browser); + let promise2 = TabStateFlusher.flush(browser); + await Promise.all([promise1, promise2]); + + // The pending update should be lost. + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 1, "still only one history entry"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +add_task(async function test_remove() { + // Create new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to empty any queued update messages. + await TabStateFlusher.flush(browser); + + // There should be one history entry. + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is a single history entry"); + + // Click the link to navigate. + await SpecialPowers.spawn(browser, [], async function() { + return new Promise(resolve => { + docShell.chromeEventHandler.addEventListener( + "hashchange", + () => resolve(), + { once: true, capture: true } + ); + + // Click the link. + content.document.querySelector("a").click(); + }); + }); + + // Request a flush and remove the tab. The flush should still complete. + await Promise.all([ + TabStateFlusher.flush(browser), + promiseRemoveTabAndSessionState(tab), + ]); +}); diff --git a/browser/components/sessionstore/test/browser_async_remove_tab.js b/browser/components/sessionstore/test/browser_async_remove_tab.js new file mode 100644 index 0000000000..ce65830560 --- /dev/null +++ b/browser/components/sessionstore/test/browser_async_remove_tab.js @@ -0,0 +1,209 @@ +"use strict"; + +async function createTabWithRandomValue(url) { + let tab = BrowserTestUtils.addTab(gBrowser, url); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Set a random value. + let r = `rand-${Math.random()}`; + ss.setCustomTabValue(tab, "foobar", r); + + // Flush to ensure there are no scheduled messages. + await TabStateFlusher.flush(browser); + + return { tab, r }; +} + +function isValueInClosedData(rval) { + return JSON.stringify(ss.getClosedTabData(window)).includes(rval); +} + +function restoreClosedTabWithValue(rval) { + let closedTabData = ss.getClosedTabData(window); + let index = closedTabData.findIndex(function(data) { + return (data.state.extData && data.state.extData.foobar) == rval; + }); + + if (index == -1) { + throw new Error("no closed tab found for given rval"); + } + + return ss.undoCloseTab(window, index); +} + +add_task(async function dont_save_empty_tabs() { + let { tab, r } = await createTabWithRandomValue("about:blank"); + + // Remove the tab before the update arrives. + let promise = promiseRemoveTabAndSessionState(tab); + + // No tab state worth saving. + ok(!isValueInClosedData(r), "closed tab not saved"); + await promise; + + // Still no tab state worth saving. + ok(!isValueInClosedData(r), "closed tab not saved"); +}); + +add_task(async function save_worthy_tabs_remote() { + let { tab, r } = await createTabWithRandomValue("https://example.com/"); + ok(tab.linkedBrowser.isRemoteBrowser, "browser is remote"); + + // Remove the tab before the update arrives. + let promise = promiseRemoveTabAndSessionState(tab); + + // Tab state deemed worth saving. + ok(isValueInClosedData(r), "closed tab saved"); + await promise; + + // Tab state still deemed worth saving. + ok(isValueInClosedData(r), "closed tab saved"); +}); + +add_task(async function save_worthy_tabs_nonremote() { + let { tab, r } = await createTabWithRandomValue("about:robots"); + ok(!tab.linkedBrowser.isRemoteBrowser, "browser is not remote"); + + // Remove the tab before the update arrives. + let promise = promiseRemoveTabAndSessionState(tab); + + // Tab state deemed worth saving. + ok(isValueInClosedData(r), "closed tab saved"); + await promise; + + // Tab state still deemed worth saving. + ok(isValueInClosedData(r), "closed tab saved"); +}); + +add_task(async function save_worthy_tabs_remote_final() { + let { tab, r } = await createTabWithRandomValue("about:blank"); + let browser = tab.linkedBrowser; + ok(browser.isRemoteBrowser, "browser is remote"); + + // Replace about:blank with a new remote page. + let entryReplaced = promiseOnHistoryReplaceEntry(browser); + browser.loadURI("https://example.com/", { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + await entryReplaced; + + // Remotness shouldn't have changed. + ok(browser.isRemoteBrowser, "browser is still remote"); + + // Remove the tab before the update arrives. + let promise = promiseRemoveTabAndSessionState(tab); + + // With SHIP, we'll do the final tab state update sooner than we did before. + if (!Services.appinfo.sessionHistoryInParent) { + // No tab state worth saving (that we know about yet). + ok(!isValueInClosedData(r), "closed tab not saved"); + } + + await promise; + + // Turns out there is a tab state worth saving. + ok(isValueInClosedData(r), "closed tab saved"); +}); + +add_task(async function save_worthy_tabs_nonremote_final() { + let { tab, r } = await createTabWithRandomValue("about:blank"); + let browser = tab.linkedBrowser; + ok(browser.isRemoteBrowser, "browser is remote"); + + // Replace about:blank with a non-remote entry. + BrowserTestUtils.loadURI(browser, "about:robots"); + await BrowserTestUtils.browserLoaded(browser); + ok(!browser.isRemoteBrowser, "browser is not remote anymore"); + + // Remove the tab before the update arrives. + let promise = promiseRemoveTabAndSessionState(tab); + + // With SHIP, we'll do the final tab state update sooner than we did before. + if (!Services.appinfo.sessionHistoryInParent) { + // No tab state worth saving (that we know about yet). + ok(!isValueInClosedData(r), "closed tab not saved"); + } + + await promise; + + // Turns out there is a tab state worth saving. + ok(isValueInClosedData(r), "closed tab saved"); +}); + +add_task(async function dont_save_empty_tabs_final() { + let { tab, r } = await createTabWithRandomValue("https://example.com/"); + let browser = tab.linkedBrowser; + ok(browser.isRemoteBrowser, "browser is remote"); + + // Replace the current page with an about:blank entry. + let entryReplaced = promiseOnHistoryReplaceEntry(browser); + + // We're doing a cross origin navigation, so we can't reliably use a + // SpecialPowers task here. Instead we just emulate a location.replace() call. + browser.loadURI("about:blank", { + loadFlags: + Ci.nsIWebNavigation.LOAD_FLAGS_STOP_CONTENT | + Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + + await entryReplaced; + + // Remove the tab before the update arrives. + let promise = promiseRemoveTabAndSessionState(tab); + + // With SHIP, we'll do the final tab state update sooner than we did before. + if (!Services.appinfo.sessionHistoryInParent) { + // Tab state deemed worth saving (yet). + ok(isValueInClosedData(r), "closed tab saved"); + } + + await promise; + + // Turns out we don't want to save the tab state. + ok(!isValueInClosedData(r), "closed tab not saved"); +}); + +add_task(async function undo_worthy_tabs() { + let { tab, r } = await createTabWithRandomValue("https://example.com/"); + ok(tab.linkedBrowser.isRemoteBrowser, "browser is remote"); + + // Remove the tab before the update arrives. + let promise = promiseRemoveTabAndSessionState(tab); + + // Tab state deemed worth saving. + ok(isValueInClosedData(r), "closed tab saved"); + + // Restore the closed tab before receiving its final message. + tab = restoreClosedTabWithValue(r); + + // Wait for the final update message. + await promise; + + // Check we didn't add the tab back to the closed list. + ok(!isValueInClosedData(r), "tab no longer closed"); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function forget_worthy_tabs_remote() { + let { tab, r } = await createTabWithRandomValue("https://example.com/"); + ok(tab.linkedBrowser.isRemoteBrowser, "browser is remote"); + + // Remove the tab before the update arrives. + let promise = promiseRemoveTabAndSessionState(tab); + + // Tab state deemed worth saving. + ok(isValueInClosedData(r), "closed tab saved"); + + // Forget the closed tab. + ss.forgetClosedTab(window, 0); + + // Wait for the final update message. + await promise; + + // Check we didn't add the tab back to the closed list. + ok(!isValueInClosedData(r), "we forgot about the tab"); +}); diff --git a/browser/components/sessionstore/test/browser_async_window_flushing.js b/browser/components/sessionstore/test/browser_async_window_flushing.js new file mode 100644 index 0000000000..108547dd81 --- /dev/null +++ b/browser/components/sessionstore/test/browser_async_window_flushing.js @@ -0,0 +1,208 @@ +"use strict"; + +const PAGE = "http://example.com/"; + +/** + * Tests that if we initially discard a window as not interesting + * to save in the closed windows array, that we revisit that decision + * after a window flush has completed. + */ +add_task(async function test_add_interesting_window() { + // We want to suppress all non-final updates from the browser tabs + // so as to eliminate any racy-ness with this test. + await pushPrefs(["browser.sessionstore.debug.no_auto_updates", true]); + + // Depending on previous tests, we might already have some closed + // windows stored. We'll use its length to determine whether or not + // the window was added or not. + let initialClosedWindows = ss.getClosedWindowCount(); + + // Make sure we can actually store another closed window + await pushPrefs([ + "browser.sessionstore.max_windows_undo", + initialClosedWindows + 1, + ]); + + // Create a new browser window. Since the default window will start + // at about:blank, SessionStore should find this tab (and therefore the + // whole window) uninteresting, and should not initially put it into + // the closed windows array. + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + let browser = newWin.gBrowser.selectedBrowser; + + // Send a message that will cause the content to change its location + // to someplace more interesting. We've disabled auto updates from + // the browser, so the parent won't know about this + await SpecialPowers.spawn(browser, [PAGE], async function(newPage) { + content.location = newPage; + }); + + await promiseOnHistoryReplaceEntry(browser); + + // Clear out the userTypedValue so that the new window looks like + // it's really not worth restoring. + browser.userTypedValue = null; + + // Once this windowClosed Promise resolves, we should have finished + // the flush and revisited our decision to put this window into + // the closed windows array. + let windowClosed = BrowserTestUtils.windowClosed(newWin); + + let handled = false; + whenDomWindowClosedHandled(() => { + // SessionStore's onClose handler should have just run. + let currentClosedWindows = ss.getClosedWindowCount(); + is( + currentClosedWindows, + initialClosedWindows, + "We should not have added the window to the closed windows array" + ); + + handled = true; + }); + + // Ok, let's close the window. + newWin.close(); + + await windowClosed; + + ok(handled, "domwindowclosed should already be handled here"); + + // The window flush has finished + let currentClosedWindows = ss.getClosedWindowCount(); + is( + currentClosedWindows, + initialClosedWindows + 1, + "We should have added the window to the closed windows array" + ); +}); + +/** + * Tests that if we initially store a closed window as interesting + * to save in the closed windows array, that we revisit that decision + * after a window flush has completed, and stop storing a window that + * we've deemed no longer interesting. + */ +add_task(async function test_remove_uninteresting_window() { + // We want to suppress all non-final updates from the browser tabs + // so as to eliminate any racy-ness with this test. + await pushPrefs(["browser.sessionstore.debug.no_auto_updates", true]); + + // Depending on previous tests, we might already have some closed + // windows stored. We'll use its length to determine whether or not + // the window was added or not. + let initialClosedWindows = ss.getClosedWindowCount(); + + // Make sure we can actually store another closed window + await pushPrefs([ + "browser.sessionstore.max_windows_undo", + initialClosedWindows + 1, + ]); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + // Now browse the initial tab of that window to an interesting + // site. + let tab = newWin.gBrowser.selectedTab; + let browser = tab.linkedBrowser; + BrowserTestUtils.loadURI(browser, PAGE); + + await BrowserTestUtils.browserLoaded(browser, false, PAGE); + await TabStateFlusher.flush(browser); + + // Send a message that will cause the content to purge its + // history entries and make itself seem uninteresting. + await SpecialPowers.spawn(browser, [], async function() { + // Epic hackery to make this browser seem suddenly boring. + docShell.setCurrentURIForSessionStore(Services.io.newURI("about:blank")); + + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + let { sessionHistory } = docShell.QueryInterface(Ci.nsIWebNavigation); + sessionHistory.legacySHistory.purgeHistory(sessionHistory.count); + } + }); + + if (SpecialPowers.Services.appinfo.sessionHistoryInParent) { + let { sessionHistory } = browser.browsingContext; + sessionHistory.purgeHistory(sessionHistory.count); + } + + // Once this windowClosed Promise resolves, we should have finished + // the flush and revisited our decision to put this window into + // the closed windows array. + let windowClosed = BrowserTestUtils.windowClosed(newWin); + + let handled = false; + whenDomWindowClosedHandled(() => { + // SessionStore's onClose handler should have just run. + let currentClosedWindows = ss.getClosedWindowCount(); + is( + currentClosedWindows, + initialClosedWindows + 1, + "We should have added the window to the closed windows array" + ); + + handled = true; + }); + + // Ok, let's close the window. + newWin.close(); + + await windowClosed; + + ok(handled, "domwindowclosed should already be handled here"); + + // The window flush has finished + let currentClosedWindows = ss.getClosedWindowCount(); + is( + currentClosedWindows, + initialClosedWindows, + "We should have removed the window from the closed windows array" + ); +}); + +/** + * Tests that when we close a window, it is immediately removed from the + * _windows array. + */ +add_task(async function test_synchronously_remove_window_state() { + // Depending on previous tests, we might already have some closed + // windows stored. We'll use its length to determine whether or not + // the window was added or not. + let state = JSON.parse(ss.getBrowserState()); + ok(state, "Make sure we can get the state"); + let initialWindows = state.windows.length; + + // Open a new window and send the first tab somewhere + // interesting. + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let browser = newWin.gBrowser.selectedBrowser; + BrowserTestUtils.loadURI(browser, PAGE); + await BrowserTestUtils.browserLoaded(browser, false, PAGE); + await TabStateFlusher.flush(browser); + + state = JSON.parse(ss.getBrowserState()); + is( + state.windows.length, + initialWindows + 1, + "The new window to be in the state" + ); + + // Now close the window, and make sure that the window was removed + // from the windows list from the SessionState. We're specifically + // testing the case where the window is _not_ removed in between + // the close-initiated flush request and the flush response. + let windowClosed = BrowserTestUtils.windowClosed(newWin); + newWin.close(); + + state = JSON.parse(ss.getBrowserState()); + is( + state.windows.length, + initialWindows, + "The new window should have been removed from the state" + ); + + // Wait for our window to go away + await windowClosed; +}); diff --git a/browser/components/sessionstore/test/browser_attributes.js b/browser/components/sessionstore/test/browser_attributes.js new file mode 100644 index 0000000000..336573a508 --- /dev/null +++ b/browser/components/sessionstore/test/browser_attributes.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test makes sure that we correctly preserve tab attributes when storing + * and restoring tabs. It also ensures that we skip special attributes like + * 'image', 'muted', and 'pending' that need to be + * handled differently or internally. + */ + +const PREF = "browser.sessionstore.restore_on_demand"; + +add_task(async function test() { + Services.prefs.setBoolPref(PREF, true); + registerCleanupFunction(() => Services.prefs.clearUserPref(PREF)); + + // Add a new tab with a nice icon. + let tab = BrowserTestUtils.addTab(gBrowser, "about:robots"); + await promiseBrowserLoaded(tab.linkedBrowser); + + // Because there is debounce logic in ContentLinkHandler.jsm to reduce the + // favicon loads, we have to wait some time before checking that icon was + // stored properly. + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon(tab) != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + + // Check that the tab has an 'image' attribute. + ok(tab.hasAttribute("image"), "tab.image exists"); + + tab.toggleMuteAudio(); + // Check that the tab has a 'muted' attribute. + ok(tab.hasAttribute("muted"), "tab.muted exists"); + + // Make sure we do not persist 'image' and 'muted' attributes. + ss.persistTabAttribute("image"); + ss.persistTabAttribute("muted"); + let { attributes } = JSON.parse(ss.getTabState(tab)); + ok(!("image" in attributes), "'image' attribute not saved"); + ok(!("muted" in attributes), "'muted' attribute not saved"); + ok(!("custom" in attributes), "'custom' attribute not saved"); + + // Test persisting a custom attribute. + tab.setAttribute("custom", "foobar"); + ss.persistTabAttribute("custom"); + + ({ attributes } = JSON.parse(ss.getTabState(tab))); + is(attributes.custom, "foobar", "'custom' attribute is correct"); + + // Make sure we're backwards compatible and restore old 'image' attributes. + let state = { + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + attributes: { custom: "foobaz" }, + image: gBrowser.getIcon(tab), + }; + + // Prepare a pending tab waiting to be restored. + let promise = promiseTabRestoring(tab); + ss.setTabState(tab, JSON.stringify(state)); + await promise; + + ok(tab.hasAttribute("pending"), "tab is pending"); + is(gBrowser.getIcon(tab), state.image, "tab has correct icon"); + ok(!state.attributes.image, "'image' attribute not saved"); + + // Let the pending tab load. + gBrowser.selectedTab = tab; + await promiseTabRestored(tab); + + // Ensure no 'image' or 'pending' attributes are stored. + ({ attributes } = JSON.parse(ss.getTabState(tab))); + ok(!("image" in attributes), "'image' attribute not saved"); + ok(!("pending" in attributes), "'pending' attribute not saved"); + is(attributes.custom, "foobaz", "'custom' attribute is correct"); + + // Clean up. + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_background_tab_crash.js b/browser/components/sessionstore/test/browser_background_tab_crash.js new file mode 100644 index 0000000000..a500d489b1 --- /dev/null +++ b/browser/components/sessionstore/test/browser_background_tab_crash.js @@ -0,0 +1,262 @@ +"use strict"; + +/** + * These tests the behaviour of the browser when background tabs crash, + * while the foreground tab remains. + * + * The current behavioural rule is this: if only background tabs crash, + * then only the first tab shown of that group should show the tab crash + * page, and subsequent ones should restore on demand. + */ + +/** + * Makes the current browser tab non-remote, and then sets up two remote + * background tabs, ensuring that both belong to the same content process. + * Callers should pass in a testing function that will execute (and possibly + * yield Promises) taking the created background tabs as arguments. Once + * the testing function completes, this function will take care of closing + * the opened tabs. + * + * @param testFn (function) + * A Promise-generating function that will be called once the tabs + * are opened and ready. + * @return Promise + * Resolves once the testing function completes and the opened tabs + * have been completely closed. + */ +async function setupBackgroundTabs(testFn) { + const REMOTE_PAGE = "http://www.example.com"; + const NON_REMOTE_PAGE = "about:mozilla"; + + // Browse the initial tab to a non-remote page, which we'll have in the + // foreground. + let initialTab = gBrowser.selectedTab; + let initialBrowser = initialTab.linkedBrowser; + BrowserTestUtils.loadURI(initialBrowser, NON_REMOTE_PAGE); + await BrowserTestUtils.browserLoaded(initialBrowser); + // Quick sanity check - the browser should be non remote. + Assert.ok( + !initialBrowser.isRemoteBrowser, + "Initial browser should not be remote." + ); + + // Open some tabs that should be running in the content process. + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, REMOTE_PAGE); + let remoteBrowser1 = tab1.linkedBrowser; + await TabStateFlusher.flush(remoteBrowser1); + + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, REMOTE_PAGE); + let remoteBrowser2 = tab2.linkedBrowser; + await TabStateFlusher.flush(remoteBrowser2); + + // Quick sanity check - the two browsers should be remote and share the + // same childID, or else this test is not going to work. + Assert.ok( + remoteBrowser1.isRemoteBrowser, + "Browser should be remote in order to crash." + ); + Assert.ok( + remoteBrowser2.isRemoteBrowser, + "Browser should be remote in order to crash." + ); + Assert.equal( + remoteBrowser1.frameLoader.childID, + remoteBrowser2.frameLoader.childID, + "Both remote browsers should share the same content process." + ); + + // Now switch back to the non-remote browser... + await BrowserTestUtils.switchTab(gBrowser, initialTab); + + await testFn([tab1, tab2]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +} + +/** + * Takes some set of background tabs that are assumed to all belong to + * the same content process, and crashes them. + * + * @param tabs (Array(<xul:tab>)) + * The tabs to crash. + * @return Promise + * Resolves once the tabs have crashed and entered the pending + * background state. + */ +async function crashBackgroundTabs(tabs) { + Assert.ok(!!tabs.length, "Need to crash at least one tab."); + for (let tab of tabs) { + Assert.ok(tab.linkedBrowser.isRemoteBrowser, "tab is remote"); + } + + let remotenessChangePromises = tabs.map(t => { + return BrowserTestUtils.waitForEvent(t, "TabRemotenessChange"); + }); + + let tabsRevived = tabs.map(t => { + return promiseTabRestoring(t); + }); + + await BrowserTestUtils.crashFrame(tabs[0].linkedBrowser, false); + await Promise.all(remotenessChangePromises); + await Promise.all(tabsRevived); + + // Both background tabs should now be in the pending restore + // state. + for (let tab of tabs) { + Assert.ok(!tab.linkedBrowser.isRemoteBrowser, "tab is not remote"); + Assert.ok(!tab.linkedBrowser.hasAttribute("crashed"), "tab is not crashed"); + Assert.ok(tab.hasAttribute("pending"), "tab is pending"); + } +} + +add_setup(async function() { + // We'll simplify by making sure we only ever one content process for this + // test. + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.ipc.processCount", 1], + ["dom.ipc.processCount.webIsolated", 1], + ], + }); + + // On debug builds, crashing tabs results in much thinking, which + // slows down the test and results in intermittent test timeouts, + // so we'll pump up the expected timeout for this test. + requestLongerTimeout(5); +}); + +/** + * Tests that if a content process crashes taking down only + * background tabs, then the first of those tabs that the user + * selects will show the tab crash page, but the rest will restore + * on demand. + */ +add_task(async function test_background_crash_simple() { + await setupBackgroundTabs(async function([tab1, tab2]) { + // Let's crash one of those background tabs now... + await crashBackgroundTabs([tab1, tab2]); + + // Selecting the first tab should now send it to the tab crashed page. + let tabCrashedPagePromise = BrowserTestUtils.waitForContentEvent( + tab1.linkedBrowser, + "AboutTabCrashedReady", + false, + null, + true + ); + await BrowserTestUtils.switchTab(gBrowser, tab1); + await tabCrashedPagePromise; + + // Selecting the second tab should restore it. + let tabRestored = promiseTabRestored(tab2); + await BrowserTestUtils.switchTab(gBrowser, tab2); + await tabRestored; + }); +}); + +/** + * Tests that if a content process crashes taking down only + * background tabs, and the user is configured to send backlogged + * crash reports automatically, that the tab crashed page is not + * shown. + */ +add_task(async function test_background_crash_autosubmit_backlogged() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.crashReports.unsubmittedCheck.autoSubmit2", true]], + }); + + await setupBackgroundTabs(async function([tab1, tab2]) { + // Let's crash one of those background tabs now... + await crashBackgroundTabs([tab1, tab2]); + + // Selecting the first tab should restore it. + let tabRestored = promiseTabRestored(tab1); + await BrowserTestUtils.switchTab(gBrowser, tab1); + await tabRestored; + + // Selecting the second tab should restore it. + tabRestored = promiseTabRestored(tab2); + await BrowserTestUtils.switchTab(gBrowser, tab2); + await tabRestored; + }); + + await SpecialPowers.popPrefEnv(); +}); + +/** + * Tests that if there are two background tab crashes in a row, that + * the two sets of background crashes don't interfere with one another. + * + * Specifically, if we start with two background tabs (1, 2) which crash, + * and we visit 1, 1 should go to the tab crashed page. If we then have + * two new background tabs (3, 4) crash, visiting 2 should still restore. + * Visiting 4 should show us the tab crashed page, and then visiting 3 + * should restore. + */ +add_task(async function test_background_crash_multiple() { + let initialTab = gBrowser.selectedTab; + + await setupBackgroundTabs(async function([tab1, tab2]) { + // Let's crash one of those background tabs now... + await crashBackgroundTabs([tab1, tab2]); + + // Selecting the first tab should now send it to the tab crashed page. + let tabCrashedPagePromise = BrowserTestUtils.waitForContentEvent( + tab1.linkedBrowser, + "AboutTabCrashedReady", + false, + null, + true + ); + await BrowserTestUtils.switchTab(gBrowser, tab1); + await tabCrashedPagePromise; + + // Now switch back to the original non-remote tab... + await BrowserTestUtils.switchTab(gBrowser, initialTab); + + await setupBackgroundTabs(async function([tab3, tab4]) { + await crashBackgroundTabs([tab3, tab4]); + + // Selecting the second tab should restore it. + let tabRestored = promiseTabRestored(tab2); + await BrowserTestUtils.switchTab(gBrowser, tab2); + await tabRestored; + + // Selecting the fourth tab should now send it to the tab crashed page. + tabCrashedPagePromise = BrowserTestUtils.waitForContentEvent( + tab4.linkedBrowser, + "AboutTabCrashedReady", + false, + null, + true + ); + await BrowserTestUtils.switchTab(gBrowser, tab4); + await tabCrashedPagePromise; + + // Selecting the third tab should restore it. + tabRestored = promiseTabRestored(tab3); + await BrowserTestUtils.switchTab(gBrowser, tab3); + await tabRestored; + }); + }); +}); + +// Tests that crashed preloaded tabs are removed and no unexpected errors are +// thrown. +add_task(async function test_preload_crash() { + if (!Services.prefs.getBoolPref("browser.newtab.preload")) { + return; + } + + // Release any existing preloaded browser + NewTabPagePreloading.removePreloadedBrowser(window); + + // Create a fresh preloaded browser + await BrowserTestUtils.maybeCreatePreloadedBrowser(gBrowser); + + await BrowserTestUtils.crashFrame(gBrowser.preloadedBrowser, false); + + Assert.ok(!gBrowser.preloadedBrowser); +}); diff --git a/browser/components/sessionstore/test/browser_backup_recovery.js b/browser/components/sessionstore/test/browser_backup_recovery.js new file mode 100644 index 0000000000..422037ae1a --- /dev/null +++ b/browser/components/sessionstore/test/browser_backup_recovery.js @@ -0,0 +1,307 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This tests are for a sessionstore.js atomic backup. +// Each test will wait for a write to the Session Store +// before executing. + +const PREF_SS_INTERVAL = "browser.sessionstore.interval"; +const Paths = SessionFile.Paths; + +// Global variables that contain sessionstore.jsonlz4 and sessionstore.baklz4 data for +// comparison between tests. +var gSSData; +var gSSBakData; + +function promiseRead(path) { + return IOUtils.readUTF8(path, { decompress: true }); +} + +async function reInitSessionFile() { + await SessionFile.wipe(); + await SessionFile.read(); +} + +add_setup(async function() { + // Make sure that we are not racing with SessionSaver's time based + // saves. + Services.prefs.setIntPref(PREF_SS_INTERVAL, 10000000); + registerCleanupFunction(() => Services.prefs.clearUserPref(PREF_SS_INTERVAL)); +}); + +add_task(async function test_creation() { + // Cancel all pending session saves so they won't get in our way. + SessionSaver.cancel(); + + // Create dummy sessionstore backups + let OLD_BACKUP = PathUtils.join(PathUtils.profileDir, "sessionstore.baklz4"); + let OLD_UPGRADE_BACKUP = PathUtils.join( + PathUtils.profileDir, + "sessionstore.baklz4-0000000" + ); + + await IOUtils.writeUTF8(OLD_BACKUP, "sessionstore.bak"); + await IOUtils.writeUTF8(OLD_UPGRADE_BACKUP, "sessionstore upgrade backup"); + + await reInitSessionFile(); + + // Ensure none of the sessionstore files and backups exists + for (let k of Paths.loadOrder) { + ok( + !(await IOUtils.exists(Paths[k])), + "After wipe " + k + " sessionstore file doesn't exist" + ); + } + ok( + !(await IOUtils.exists(OLD_BACKUP)), + "After wipe, old backup doesn't exist" + ); + ok( + !(await IOUtils.exists(OLD_UPGRADE_BACKUP)), + "After wipe, old upgrade backup doesn't exist" + ); + + // Open a new tab, save session, ensure that the correct files exist. + let URL_BASE = + "http://example.com/?atomic_backup_test_creation=" + Math.random(); + let URL = URL_BASE + "?first_write"; + let tab = BrowserTestUtils.addTab(gBrowser, URL); + + info("Testing situation after a single write"); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + await SessionSaver.run(); + + ok( + await IOUtils.exists(Paths.recovery), + "After write, recovery sessionstore file exists again" + ); + ok( + !(await IOUtils.exists(Paths.recoveryBackup)), + "After write, recoveryBackup sessionstore doesn't exist" + ); + ok( + (await promiseRead(Paths.recovery)).includes(URL), + "Recovery sessionstore file contains the required tab" + ); + ok( + !(await IOUtils.exists(Paths.clean)), + "After first write, clean shutdown " + + "sessionstore doesn't exist, since we haven't shutdown yet" + ); + + // Open a second tab, save session, ensure that the correct files exist. + info("Testing situation after a second write"); + let URL2 = URL_BASE + "?second_write"; + BrowserTestUtils.loadURI(tab.linkedBrowser, URL2); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + await SessionSaver.run(); + + ok( + await IOUtils.exists(Paths.recovery), + "After second write, recovery sessionstore file still exists" + ); + ok( + (await promiseRead(Paths.recovery)).includes(URL2), + "Recovery sessionstore file contains the latest url" + ); + ok( + await IOUtils.exists(Paths.recoveryBackup), + "After write, recoveryBackup sessionstore now exists" + ); + let backup = await promiseRead(Paths.recoveryBackup); + ok(!backup.includes(URL2), "Recovery backup doesn't contain the latest url"); + ok(backup.includes(URL), "Recovery backup contains the original url"); + ok( + !(await IOUtils.exists(Paths.clean)), + "After first write, clean shutdown " + + "sessionstore doesn't exist, since we haven't shutdown yet" + ); + + info("Reinitialize, ensure that we haven't leaked sensitive files"); + await SessionFile.read(); // Reinitializes SessionFile + await SessionSaver.run(); + ok( + !(await IOUtils.exists(Paths.clean)), + "After second write, clean shutdown " + + "sessionstore doesn't exist, since we haven't shutdown yet" + ); + ok( + Paths.upgradeBackup === "", + "After second write, clean " + + "shutdown sessionstore doesn't exist, since we haven't shutdown yet" + ); + ok( + !(await IOUtils.exists(Paths.nextUpgradeBackup)), + "After second write, clean " + + "shutdown sessionstore doesn't exist, since we haven't shutdown yet" + ); + + gBrowser.removeTab(tab); +}); + +var promiseSource = async function(name) { + let URL = + "http://example.com/?atomic_backup_test_recovery=" + + Math.random() + + "&name=" + + name; + let tab = BrowserTestUtils.addTab(gBrowser, URL); + + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + await SessionSaver.run(); + gBrowser.removeTab(tab); + + let SOURCE = await promiseRead(Paths.recovery); + await SessionFile.wipe(); + return SOURCE; +}; + +add_task(async function test_recovery() { + await reInitSessionFile(); + info("Attempting to recover from the recovery file"); + + // Create Paths.recovery, ensure that we can recover from it. + let SOURCE = await promiseSource("Paths.recovery"); + await IOUtils.makeDirectory(Paths.backups); + await IOUtils.writeUTF8(Paths.recovery, SOURCE, { compress: true }); + is( + (await SessionFile.read()).source, + SOURCE, + "Recovered the correct source from the recovery file" + ); + + info("Corrupting recovery file, attempting to recover from recovery backup"); + SOURCE = await promiseSource("Paths.recoveryBackup"); + await IOUtils.makeDirectory(Paths.backups); + await IOUtils.writeUTF8(Paths.recoveryBackup, SOURCE, { compress: true }); + await IOUtils.writeUTF8(Paths.recovery, "<Invalid JSON>", { compress: true }); + is( + (await SessionFile.read()).source, + SOURCE, + "Recovered the correct source from the recovery file" + ); +}); + +add_task(async function test_recovery_inaccessible() { + // Can't do chmod() on non-UNIX platforms, we need that for this test. + if (AppConstants.platform != "macosx" && AppConstants.platform != "linux") { + return; + } + + await reInitSessionFile(); + info( + "Making recovery file inaccessible, attempting to recover from recovery backup" + ); + let SOURCE_RECOVERY = await promiseSource("Paths.recovery"); + let SOURCE = await promiseSource("Paths.recoveryBackup"); + await IOUtils.makeDirectory(Paths.backups); + await IOUtils.writeUTF8(Paths.recoveryBackup, SOURCE, { compress: true }); + + // Write a valid recovery file but make it inaccessible. + await IOUtils.writeUTF8(Paths.recovery, SOURCE_RECOVERY, { compress: true }); + await IOUtils.setPermissions(Paths.recovery, 0); + + is( + (await SessionFile.read()).source, + SOURCE, + "Recovered the correct source from the recovery file" + ); + await IOUtils.setPermissions(Paths.recovery, 0o644); +}); + +add_task(async function test_clean() { + await reInitSessionFile(); + let SOURCE = await promiseSource("Paths.clean"); + await IOUtils.writeUTF8(Paths.clean, SOURCE, { compress: true }); + await SessionFile.read(); + await SessionSaver.run(); + is( + await promiseRead(Paths.cleanBackup), + SOURCE, + "After first read/write, " + + "clean shutdown file has been moved to cleanBackup" + ); +}); + +/** + * Tests loading of sessionstore when format version is known. + */ +add_task(async function test_version() { + info("Preparing sessionstore"); + let SOURCE = await promiseSource("Paths.clean"); + + // Check there's a format version number + is( + JSON.parse(SOURCE).version[0], + "sessionrestore", + "Found sessionstore format version" + ); + + // Create Paths.clean file + await IOUtils.makeDirectory(Paths.backups); + await IOUtils.writeUTF8(Paths.clean, SOURCE, { compress: true }); + + info("Attempting to recover from the clean file"); + // Ensure that we can recover from Paths.recovery + is( + (await SessionFile.read()).source, + SOURCE, + "Recovered the correct source from the clean file" + ); +}); + +/** + * Tests fallback to previous backups if format version is unknown. + */ +add_task(async function test_version_fallback() { + await reInitSessionFile(); + info("Preparing data, making sure that it has a version number"); + let SOURCE = await promiseSource("Paths.clean"); + let BACKUP_SOURCE = await promiseSource("Paths.cleanBackup"); + + is( + JSON.parse(SOURCE).version[0], + "sessionrestore", + "Found sessionstore format version" + ); + is( + JSON.parse(BACKUP_SOURCE).version[0], + "sessionrestore", + "Found backup sessionstore format version" + ); + + await IOUtils.makeDirectory(Paths.backups); + + info( + "Modifying format version number to something incorrect, to make sure that we disregard the file." + ); + let parsedSource = JSON.parse(SOURCE); + parsedSource.version[0] = "bookmarks"; + await IOUtils.writeJSON(Paths.clean, parsedSource, { compress: true }); + await IOUtils.writeUTF8(Paths.cleanBackup, BACKUP_SOURCE, { compress: true }); + is( + (await SessionFile.read()).source, + BACKUP_SOURCE, + "Recovered the correct source from the backup recovery file" + ); + + info( + "Modifying format version number to a future version, to make sure that we disregard the file." + ); + parsedSource = JSON.parse(SOURCE); + parsedSource.version[1] = Number.MAX_SAFE_INTEGER; + await IOUtils.writeJSON(Paths.clean, parsedSource, { compress: true }); + await IOUtils.writeUTF8(Paths.cleanBackup, BACKUP_SOURCE, { compress: true }); + is( + (await SessionFile.read()).source, + BACKUP_SOURCE, + "Recovered the correct source from the backup recovery file" + ); +}); + +add_task(async function cleanup() { + await reInitSessionFile(); +}); diff --git a/browser/components/sessionstore/test/browser_bfcache_telemetry.js b/browser/components/sessionstore/test/browser_bfcache_telemetry.js new file mode 100644 index 0000000000..a4441f91bc --- /dev/null +++ b/browser/components/sessionstore/test/browser_bfcache_telemetry.js @@ -0,0 +1,45 @@ +const URL1 = "data:text/html;charset=utf-8,<body><p>Hello1</p></body>"; +const URL2 = "data:text/html;charset=utf-8,<body><p>Hello2</p></body>"; + +async function getBFCacheComboTelemetry(probeInParent) { + let bfcacheCombo; + await TestUtils.waitForCondition(() => { + let histograms; + if (probeInParent) { + histograms = Services.telemetry.getSnapshotForHistograms( + "main", + false /* clear */ + ).parent; + } else { + histograms = Services.telemetry.getSnapshotForHistograms( + "main", + false /* clear */ + ).content; + } + bfcacheCombo = histograms.BFCACHE_COMBO; + return bfcacheCombo; + }); + return bfcacheCombo; +} + +async function test_bfcache_telemetry(probeInParent) { + Services.telemetry.getHistogramById("BFCACHE_COMBO").clear(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL1); + + BrowserTestUtils.loadURI(tab.linkedBrowser, URL2); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + let bfcacheCombo = await getBFCacheComboTelemetry(probeInParent); + + is(bfcacheCombo.values[0], 1, "1 bfcache success"); + + gBrowser.removeTab(tab); +} + +add_task(async () => { + await test_bfcache_telemetry( + Services.appinfo.sessionHistoryInParent && + Services.prefs.getBoolPref("fission.bfcacheInParent") + ); +}); diff --git a/browser/components/sessionstore/test/browser_broadcast.js b/browser/components/sessionstore/test/browser_broadcast.js new file mode 100644 index 0000000000..c576f66418 --- /dev/null +++ b/browser/components/sessionstore/test/browser_broadcast.js @@ -0,0 +1,162 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const INITIAL_VALUE = "browser_broadcast.js-initial-value-" + Date.now(); + +/** + * This test ensures we won't lose tab data queued in the content script when + * closing a tab. + */ +add_task(async function flush_on_tabclose() { + let tab = await createTabWithStorageData(["http://example.com/"]); + let browser = tab.linkedBrowser; + + await modifySessionStorage(browser, { test: "on-tab-close" }); + await TabStateFlusher.flush(browser); + await promiseRemoveTabAndSessionState(tab); + + let [ + { + state: { storage }, + }, + ] = ss.getClosedTabData(window); + is( + storage["http://example.com"].test, + "on-tab-close", + "sessionStorage data has been flushed on TabClose" + ); +}); + +/** + * This test ensures we won't lose tab data queued in the content script when + * duplicating a tab. + */ +add_task(async function flush_on_duplicate() { + let tab = await createTabWithStorageData(["http://example.com/"]); + let browser = tab.linkedBrowser; + + await modifySessionStorage(browser, { test: "on-duplicate" }); + let tab2 = ss.duplicateTab(window, tab); + await promiseTabRestored(tab2); + + await promiseRemoveTabAndSessionState(tab2); + let [ + { + state: { storage }, + }, + ] = ss.getClosedTabData(window); + is( + storage["http://example.com"].test, + "on-duplicate", + "sessionStorage data has been flushed when duplicating tabs" + ); + + gBrowser.removeTab(tab); +}); + +/** + * This test ensures we won't lose tab data queued in the content script when + * a window is closed. + */ +add_task(async function flush_on_windowclose() { + let win = await promiseNewWindow(); + let tab = await createTabWithStorageData(["http://example.com/"], win); + let browser = tab.linkedBrowser; + + await modifySessionStorage(browser, { test: "on-window-close" }); + await BrowserTestUtils.closeWindow(win); + + let [ + { + tabs: [, { storage }], + }, + ] = ss.getClosedWindowData(); + is( + storage["http://example.com"].test, + "on-window-close", + "sessionStorage data has been flushed when closing a window" + ); +}); + +/** + * This test ensures that stale tab data is ignored when reusing a tab + * (via e.g. setTabState) and does not overwrite the new data. + */ +add_task(async function flush_on_settabstate() { + let tab = await createTabWithStorageData(["http://example.com/"]); + let browser = tab.linkedBrowser; + + // Flush to make sure our tab state is up-to-date. + await TabStateFlusher.flush(browser); + + let state = ss.getTabState(tab); + await modifySessionStorage(browser, { test: "on-set-tab-state" }); + + // Flush all data contained in the content script but send it using + // asynchronous messages. + TabStateFlusher.flush(browser); + + await promiseTabState(tab, state); + + let { storage } = JSON.parse(ss.getTabState(tab)); + is( + storage["http://example.com"].test, + INITIAL_VALUE, + "sessionStorage data has not been overwritten" + ); + + gBrowser.removeTab(tab); +}); + +/** + * This test ensures that we won't lose tab data that has been sent + * asynchronously just before closing a tab. Flushing must re-send all data + * that hasn't been received by chrome, yet. + */ +add_task(async function flush_on_tabclose_racy() { + let tab = await createTabWithStorageData(["http://example.com/"]); + let browser = tab.linkedBrowser; + + // Flush to make sure we start with an empty queue. + await TabStateFlusher.flush(browser); + + await modifySessionStorage(browser, { test: "on-tab-close-racy" }); + + // Flush all data contained in the content script but send it using + // asynchronous messages. + TabStateFlusher.flush(browser); + await promiseRemoveTabAndSessionState(tab); + + let [ + { + state: { storage }, + }, + ] = ss.getClosedTabData(window); + is( + storage["http://example.com"].test, + "on-tab-close-racy", + "sessionStorage data has been merged correctly to prevent data loss" + ); +}); + +function promiseNewWindow() { + return new Promise(resolve => { + whenNewWindowLoaded({ private: false }, resolve); + }); +} + +async function createTabWithStorageData(urls, win = window) { + let tab = BrowserTestUtils.addTab(win.gBrowser); + let browser = tab.linkedBrowser; + + for (let url of urls) { + BrowserTestUtils.loadURI(browser, url); + await promiseBrowserLoaded(browser, true, url); + dump("Loaded url: " + url + "\n"); + await modifySessionStorage(browser, { test: INITIAL_VALUE }); + } + + return tab; +} diff --git a/browser/components/sessionstore/test/browser_capabilities.js b/browser/components/sessionstore/test/browser_capabilities.js new file mode 100644 index 0000000000..f02b0ede76 --- /dev/null +++ b/browser/components/sessionstore/test/browser_capabilities.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * These tests ensures that disabling features by flipping nsIDocShell.allow* + * properties are (re)stored as disabled. Disallowed features must be + * re-enabled when the tab is re-used by another tab restoration. + */ +add_task(async function docshell_capabilities() { + let tab = await createTab(); + let browser = tab.linkedBrowser; + let { browsingContext, docShell } = browser; + + // Get the list of capabilities for docShells. + let flags = Object.keys(docShell).filter(k => k.startsWith("allow")); + + // Check that everything is allowed by default for new tabs. + let state = JSON.parse(ss.getTabState(tab)); + ok(!("disallow" in state), "everything allowed by default"); + ok( + flags.every(f => docShell[f]), + "all flags set to true" + ); + + // Flip a couple of allow* flags. + docShell.allowImages = false; + docShell.allowMetaRedirects = false; + browsingContext.allowJavascript = false; + + // Now reload the document to ensure that these capabilities + // are taken into account. + browser.reload(); + await promiseBrowserLoaded(browser); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser); + + // Check that we correctly save disallowed features. + let disallowedState = JSON.parse(ss.getTabState(tab)); + let disallow = new Set(disallowedState.disallow.split(",")); + ok(disallow.has("Images"), "images not allowed"); + ok(disallow.has("MetaRedirects"), "meta redirects not allowed"); + is(disallow.size, 2, "two capabilities disallowed"); + + // Reuse the tab to restore a new, clean state into it. + await promiseTabState(tab, { + entries: [{ url: "about:robots", triggeringPrincipal_base64 }], + }); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser); + + // After restoring disallowed features must be available again. + state = JSON.parse(ss.getTabState(tab)); + ok(!("disallow" in state), "everything allowed again"); + ok( + flags.every(f => docShell[f]), + "all flags set to true" + ); + + // Restore the state with disallowed features. + await promiseTabState(tab, disallowedState); + + // Check that docShell flags are set. + ok(!docShell.allowImages, "images not allowed"); + ok(!docShell.allowMetaRedirects, "meta redirects not allowed"); + + // Check that docShell allowJavascript flag is not set. + ok(browsingContext.allowJavascript, "Javascript still allowed"); + + // Check that we correctly restored features as disabled. + state = JSON.parse(ss.getTabState(tab)); + disallow = new Set(state.disallow.split(",")); + ok(disallow.has("Images"), "images not allowed anymore"); + ok(disallow.has("MetaRedirects"), "meta redirects not allowed anymore"); + ok(!disallow.has("Javascript"), "Javascript still allowed"); + is(disallow.size, 2, "two capabilities disallowed"); + + // Clean up after ourselves. + gBrowser.removeTab(tab); +}); + +async function createTab() { + let tab = BrowserTestUtils.addTab(gBrowser, "about:rights"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + return tab; +} diff --git a/browser/components/sessionstore/test/browser_cleaner.js b/browser/components/sessionstore/test/browser_cleaner.js new file mode 100644 index 0000000000..ce852f4dde --- /dev/null +++ b/browser/components/sessionstore/test/browser_cleaner.js @@ -0,0 +1,216 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * This test ensures that Session Restore eventually forgets about + * tabs and windows that have been closed a long time ago. + */ + +"use strict"; + +const LONG_TIME_AGO = 1; + +const URL_TAB1 = + "http://example.com/browser_cleaner.js?newtab1=" + Math.random(); +const URL_TAB2 = + "http://example.com/browser_cleaner.js?newtab2=" + Math.random(); +const URL_NEWWIN = + "http://example.com/browser_cleaner.js?newwin=" + Math.random(); + +function isRecent(stamp) { + is(typeof stamp, "number", "This is a timestamp"); + return Date.now() - stamp <= 60000; +} + +function promiseCleanup() { + info("Cleaning up browser"); + + return promiseBrowserState(getClosedState()); +} + +function getClosedState() { + return Cu.cloneInto(CLOSED_STATE, {}); +} + +var CLOSED_STATE; + +add_setup(async function() { + forgetClosedWindows(); + while (ss.getClosedTabCount(window) > 0) { + ss.forgetClosedTab(window, 0); + } +}); + +add_task(async function test_open_and_close() { + let newTab1 = BrowserTestUtils.addTab(gBrowser, URL_TAB1); + await promiseBrowserLoaded(newTab1.linkedBrowser); + + let newTab2 = BrowserTestUtils.addTab(gBrowser, URL_TAB2); + await promiseBrowserLoaded(newTab2.linkedBrowser); + + let newWin = await promiseNewWindowLoaded(); + let tab = BrowserTestUtils.addTab(newWin.gBrowser, URL_NEWWIN); + + await promiseBrowserLoaded(tab.linkedBrowser); + + await TabStateFlusher.flushWindow(window); + await TabStateFlusher.flushWindow(newWin); + + info("1. Making sure that before closing, we don't have closedAt"); + // For the moment, no "closedAt" + let state = JSON.parse(ss.getBrowserState()); + is( + state.windows[0].closedAt || false, + false, + "1. Main window doesn't have closedAt" + ); + is( + state.windows[1].closedAt || false, + false, + "1. Second window doesn't have closedAt" + ); + is( + state.windows[0].tabs[0].closedAt || false, + false, + "1. First tab doesn't have closedAt" + ); + is( + state.windows[0].tabs[1].closedAt || false, + false, + "1. Second tab doesn't have closedAt" + ); + + info("2. Making sure that after closing, we have closedAt"); + + // Now close stuff, this should add closeAt + await BrowserTestUtils.closeWindow(newWin); + await promiseRemoveTabAndSessionState(newTab1); + await promiseRemoveTabAndSessionState(newTab2); + + state = CLOSED_STATE = JSON.parse(ss.getBrowserState()); + + is( + state.windows[0].closedAt || false, + false, + "2. Main window doesn't have closedAt" + ); + ok( + isRecent(state._closedWindows[0].closedAt), + "2. Second window was closed recently" + ); + ok( + isRecent(state.windows[0]._closedTabs[0].closedAt), + "2. First tab was closed recently" + ); + ok( + isRecent(state.windows[0]._closedTabs[1].closedAt), + "2. Second tab was closed recently" + ); +}); + +add_task(async function test_restore() { + info("3. Making sure that after restoring, we don't have closedAt"); + await promiseBrowserState(CLOSED_STATE); + + let newWin = ss.undoCloseWindow(0); + await promiseDelayedStartupFinished(newWin); + + let newTab2 = ss.undoCloseTab(window, 0); + await promiseTabRestored(newTab2); + + let newTab1 = ss.undoCloseTab(window, 0); + await promiseTabRestored(newTab1); + + let state = JSON.parse(ss.getBrowserState()); + + is( + state.windows[0].closedAt || false, + false, + "3. Main window doesn't have closedAt" + ); + is( + state.windows[1].closedAt || false, + false, + "3. Second window doesn't have closedAt" + ); + is( + state.windows[0].tabs[0].closedAt || false, + false, + "3. First tab doesn't have closedAt" + ); + is( + state.windows[0].tabs[1].closedAt || false, + false, + "3. Second tab doesn't have closedAt" + ); + + await BrowserTestUtils.closeWindow(newWin); + gBrowser.removeTab(newTab1); + gBrowser.removeTab(newTab2); +}); + +add_task(async function test_old_data() { + info( + "4. Removing closedAt from the sessionstore, making sure that it is added upon idle-daily" + ); + + let state = getClosedState(); + delete state._closedWindows[0].closedAt; + delete state.windows[0]._closedTabs[0].closedAt; + delete state.windows[0]._closedTabs[1].closedAt; + await promiseBrowserState(state); + + info("Sending idle-daily"); + Services.obs.notifyObservers(null, "idle-daily"); + info("Sent idle-daily"); + + state = JSON.parse(ss.getBrowserState()); + is( + state.windows[0].closedAt || false, + false, + "4. Main window doesn't have closedAt" + ); + ok( + isRecent(state._closedWindows[0].closedAt), + "4. Second window was closed recently" + ); + ok( + isRecent(state.windows[0]._closedTabs[0].closedAt), + "4. First tab was closed recently" + ); + ok( + isRecent(state.windows[0]._closedTabs[1].closedAt), + "4. Second tab was closed recently" + ); + await promiseCleanup(); +}); + +add_task(async function test_cleanup() { + info( + "5. Altering closedAt to an old date, making sure that stuff gets collected, eventually" + ); + await promiseCleanup(); + + let state = getClosedState(); + state._closedWindows[0].closedAt = LONG_TIME_AGO; + state.windows[0]._closedTabs[0].closedAt = LONG_TIME_AGO; + state.windows[0]._closedTabs[1].closedAt = Date.now(); + let url = state.windows[0]._closedTabs[1].state.entries[0].url; + + await promiseBrowserState(state); + + info("Sending idle-daily"); + Services.obs.notifyObservers(null, "idle-daily"); + info("Sent idle-daily"); + + state = JSON.parse(ss.getBrowserState()); + is(state._closedWindows[0], undefined, "5. Second window was forgotten"); + + is(state.windows[0]._closedTabs.length, 1, "5. Only one closed tab left"); + is( + state.windows[0]._closedTabs[0].state.entries[0].url, + url, + "5. The second tab is still here" + ); + await promiseCleanup(); +}); diff --git a/browser/components/sessionstore/test/browser_closedId.js b/browser/components/sessionstore/test/browser_closedId.js new file mode 100644 index 0000000000..33d17bd33e --- /dev/null +++ b/browser/components/sessionstore/test/browser_closedId.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SessionStore } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" +); +const BACKUP_STATE = SessionStore.getBrowserState(); + +async function add_new_tab(URL) { + let tab = BrowserTestUtils.addTab(gBrowser, URL); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + return tab; +} + +add_task(async function test_closedId_order() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.sessionstore.restore_on_demand", true], + ["browser.sessionstore.restore_tabs_lazily", true], + ], + }); + // reset to 0 + SessionStore.resetNextClosedId(); + await promiseBrowserState({ + windows: [ + { + selected: 1, // SessionStore uses 1-based indexing. + tabs: [ + { + entries: [], + }, + ], + _closedTabs: [ + { + state: { + entries: [ + { + url: "https://www.example.com/", + triggeringPrincipal_base64, + }, + ], + selected: 1, + }, + closedId: 0, + closedAt: Date.now() - 100, + }, + { + state: { + entries: [ + { + url: "about:mozilla", + triggeringPrincipal_base64, + }, + ], + }, + closedId: 1, + closedAt: Date.now() - 50, + }, + { + state: { + entries: [ + { + url: "https://www.example.net/", + triggeringPrincipal_base64, + }, + ], + }, + closedId: 2, + closedAt: Date.now(), + }, + ], + }, + ], + }); + + let tab = await add_new_tab("about:firefoxview"); + + is( + SessionStore.getClosedTabCount(window), + 3, + "Closed tab count after restored session is 3" + ); + + let initialClosedId = SessionStore.getClosedTabData(window)[0].closedId; + + // If this fails, that means one of the closedId's in the stubbed data in this test needs to be updated + // to reflect what the initial closedId is when a new tab is open and closed (which may change as more tests + // for session store are added here). You can manually verify a change to stubbed data by commenting out + // this._resetClosedIds in SessionStore.sys.mjs temporarily and then the "Each tab has a unique closedId" case should fail. + is(initialClosedId, 0, "Initial closedId is 0"); + + await openAndCloseTab(window, "about:robots"); // closedId should be higher than the ones we just restored. + + let closedData = SessionStore.getClosedTabData(window); + is(closedData.length, 4, "Should have data for 4 closed tabs."); + is( + new Set(closedData.map(t => t.closedId)).size, + 4, + "Each tab has a unique closedId" + ); + + BrowserTestUtils.removeTab(tab); + + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); diff --git a/browser/components/sessionstore/test/browser_closed_objects_changed_notifications_tabs.js b/browser/components/sessionstore/test/browser_closed_objects_changed_notifications_tabs.js new file mode 100644 index 0000000000..1f1872b847 --- /dev/null +++ b/browser/components/sessionstore/test/browser_closed_objects_changed_notifications_tabs.js @@ -0,0 +1,136 @@ +"use strict"; + +/** + * This test is for the sessionstore-closed-objects-changed notifications. + */ + +const MAX_TABS_UNDO_PREF = "browser.sessionstore.max_tabs_undo"; +const TOPIC = "sessionstore-closed-objects-changed"; + +let notificationsCount = 0; + +async function openWindow(url) { + let win = await promiseNewWindowLoaded(); + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY; + BrowserTestUtils.loadURI(win.gBrowser.selectedBrowser, url, { flags }); + await promiseBrowserLoaded(win.gBrowser.selectedBrowser, true, url); + return win; +} + +async function closeWindow(win) { + await awaitNotification(() => BrowserTestUtils.closeWindow(win)); +} + +async function openAndCloseWindow(url) { + let win = await openWindow(url); + await closeWindow(win); +} + +async function openTab(window, url) { + let tab = await BrowserTestUtils.openNewForegroundTab(window.gBrowser, url); + await TabStateFlusher.flush(tab.linkedBrowser); + return tab; +} + +async function openAndCloseTab(window, url) { + let tab = await openTab(window, url); + await promiseRemoveTabAndSessionState(tab); +} + +function countingObserver() { + notificationsCount++; +} + +function assertNotificationCount(count) { + is( + notificationsCount, + count, + "The expected number of notifications was received." + ); +} + +async function awaitNotification(callback) { + let notification = TestUtils.topicObserved(TOPIC); + executeSoon(callback); + await notification; +} + +add_task(async function test_closedObjectsChangedNotifications() { + // Create a closed window so that when we do the purge we can expect a notification. + await openAndCloseWindow("about:robots"); + + // Forget any previous closed windows or tabs from other tests that may have + // run in the same session. + await awaitNotification(() => + Services.obs.notifyObservers(null, "browser:purge-session-history") + ); + + // Add an observer to count the number of notifications. + Services.obs.addObserver(countingObserver, TOPIC); + + // Open a new window. + let win = await openWindow("about:robots"); + + info("Opening and closing a tab."); + await openAndCloseTab(win, "about:mozilla"); + assertNotificationCount(1); + + info("Opening and closing a second tab."); + await openAndCloseTab(win, "about:mozilla"); + assertNotificationCount(2); + + info(`Changing the ${MAX_TABS_UNDO_PREF} pref.`); + registerCleanupFunction(function() { + Services.prefs.clearUserPref(MAX_TABS_UNDO_PREF); + }); + await awaitNotification(() => + Services.prefs.setIntPref(MAX_TABS_UNDO_PREF, 1) + ); + assertNotificationCount(3); + + info("Undoing close of remaining closed tab."); + let tab = SessionStore.undoCloseTab(win, 0); + await promiseTabRestored(tab); + assertNotificationCount(4); + + info("Closing tab again."); + await promiseRemoveTabAndSessionState(tab); + assertNotificationCount(5); + + info("Purging session history."); + await awaitNotification(() => + Services.obs.notifyObservers(null, "browser:purge-session-history") + ); + assertNotificationCount(6); + + info("Opening and closing another tab."); + await openAndCloseTab(win, "http://example.com/"); + assertNotificationCount(7); + + info("Purging domain data with no matches."); + Services.obs.notifyObservers( + null, + "browser:purge-session-history-for-domain", + "mozilla.com" + ); + assertNotificationCount(7); + + info("Purging domain data with matches."); + await awaitNotification(() => + Services.obs.notifyObservers( + null, + "browser:purge-session-history-for-domain", + "example.com" + ) + ); + assertNotificationCount(8); + + info("Opening and closing another tab."); + await openAndCloseTab(win, "http://example.com/"); + assertNotificationCount(9); + + await closeWindow(win); + assertNotificationCount(10); + + Services.obs.removeObserver(countingObserver, TOPIC); +}); diff --git a/browser/components/sessionstore/test/browser_closed_objects_changed_notifications_windows.js b/browser/components/sessionstore/test/browser_closed_objects_changed_notifications_windows.js new file mode 100644 index 0000000000..14f4b6a317 --- /dev/null +++ b/browser/components/sessionstore/test/browser_closed_objects_changed_notifications_windows.js @@ -0,0 +1,129 @@ +"use strict"; + +/** + * This test is for the sessionstore-closed-objects-changed notifications. + */ + +requestLongerTimeout(2); + +const MAX_WINDOWS_UNDO_PREF = "browser.sessionstore.max_windows_undo"; +const TOPIC = "sessionstore-closed-objects-changed"; + +let notificationsCount = 0; + +async function openWindow(url) { + let win = await promiseNewWindowLoaded(); + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY; + BrowserTestUtils.loadURI(win.gBrowser.selectedBrowser, url, { flags }); + await promiseBrowserLoaded(win.gBrowser.selectedBrowser, true, url); + return win; +} + +async function closeWindow(win) { + await awaitNotification(() => BrowserTestUtils.closeWindow(win)); +} + +async function openAndCloseWindow(url) { + let win = await openWindow(url); + await closeWindow(win); +} + +function countingObserver() { + notificationsCount++; +} + +function assertNotificationCount(count) { + is( + notificationsCount, + count, + "The expected number of notifications was received." + ); +} + +async function awaitNotification(callback) { + let notification = TestUtils.topicObserved(TOPIC); + executeSoon(callback); + await notification; +} + +add_task(async function test_closedObjectsChangedNotifications() { + // Create a closed window so that when we do the purge we know to expect a notification + await openAndCloseWindow("about:robots"); + + // Forget any previous closed windows or tabs from other tests that may have + // run in the same session. + await awaitNotification(() => + Services.obs.notifyObservers(null, "browser:purge-session-history") + ); + + // Add an observer to count the number of notifications. + Services.obs.addObserver(countingObserver, TOPIC); + + info("Opening and closing initial window."); + await openAndCloseWindow("about:robots"); + assertNotificationCount(1); + + // Store state with a single closed window for use in later tests. + let closedState = Cu.cloneInto(JSON.parse(ss.getBrowserState()), {}); + + info("Undoing close of initial window."); + let win = SessionStore.undoCloseWindow(0); + await promiseDelayedStartupFinished(win); + assertNotificationCount(2); + + // Open a second window. + let win2 = await openWindow("about:mozilla"); + + info("Closing both windows."); + await closeWindow(win); + assertNotificationCount(3); + await closeWindow(win2); + assertNotificationCount(4); + + info(`Changing the ${MAX_WINDOWS_UNDO_PREF} pref.`); + registerCleanupFunction(function() { + Services.prefs.clearUserPref(MAX_WINDOWS_UNDO_PREF); + }); + await awaitNotification(() => + Services.prefs.setIntPref(MAX_WINDOWS_UNDO_PREF, 1) + ); + assertNotificationCount(5); + + info("Forgetting a closed window."); + await awaitNotification(() => SessionStore.forgetClosedWindow()); + assertNotificationCount(6); + + info("Opening and closing another window."); + await openAndCloseWindow("about:robots"); + assertNotificationCount(7); + + info("Setting browser state to trigger change onIdleDaily."); + let state = Cu.cloneInto(JSON.parse(ss.getBrowserState()), {}); + state._closedWindows[0].closedAt = 1; + await promiseBrowserState(state); + assertNotificationCount(8); + + info("Sending idle-daily"); + await awaitNotification(() => + Services.obs.notifyObservers(null, "idle-daily") + ); + assertNotificationCount(9); + + info("Opening and closing another window."); + await openAndCloseWindow("about:robots"); + assertNotificationCount(10); + + info("Purging session history."); + await awaitNotification(() => + Services.obs.notifyObservers(null, "browser:purge-session-history") + ); + assertNotificationCount(11); + + info("Setting window state."); + win = await openWindow("about:mozilla"); + await awaitNotification(() => SessionStore.setWindowState(win, closedState)); + assertNotificationCount(12); + + Services.obs.removeObserver(countingObserver, TOPIC); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_cookies.js b/browser/components/sessionstore/test/browser_cookies.js new file mode 100644 index 0000000000..f514efc777 --- /dev/null +++ b/browser/components/sessionstore/test/browser_cookies.js @@ -0,0 +1,81 @@ +"use strict"; + +function promiseSetCookie(cookie) { + info(`Set-Cookie: ${cookie}`); + return Promise.all([ + waitForCookieChanged(), + SpecialPowers.spawn( + gBrowser.selectedBrowser, + [cookie], + passedCookie => (content.document.cookie = passedCookie) + ), + ]); +} + +function waitForCookieChanged() { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subj, topic, data) { + Services.obs.removeObserver(observer, topic); + resolve(); + }, "session-cookie-changed"); + }); +} + +function cookieExists(host, name, value) { + let { + cookies: [c], + } = JSON.parse(ss.getBrowserState()); + return c && c.host == host && c.name == name && c.value == value; +} + +// Setup and cleanup. +add_task(async function test_setup() { + registerCleanupFunction(() => { + Services.cookies.removeAll(); + }); +}); + +// Test session cookie storage. +add_task(async function test_run() { + Services.cookies.removeAll(); + + // Add a new tab for testing. + gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "http://example.com/" + ); + await promiseBrowserLoaded(gBrowser.selectedBrowser); + + // Add a session cookie. + await promiseSetCookie("foo=bar"); + ok(cookieExists("example.com", "foo", "bar"), "cookie was added"); + + // Modify a session cookie. + await promiseSetCookie("foo=baz"); + ok(cookieExists("example.com", "foo", "baz"), "cookie was modified"); + + // Turn the session cookie into a normal one. + let expiry = new Date(); + expiry.setDate(expiry.getDate() + 2); + await promiseSetCookie(`foo=baz; Expires=${expiry.toUTCString()}`); + ok(!cookieExists("example.com", "foo", "baz"), "no longer a session cookie"); + + // Turn it back into a session cookie. + await promiseSetCookie("foo=bar"); + ok(cookieExists("example.com", "foo", "bar"), "again a session cookie"); + + // Remove the session cookie. + await promiseSetCookie("foo=; Expires=Thu, 01 Jan 1970 00:00:00 GMT"); + ok(!cookieExists("example.com", "foo", ""), "cookie was removed"); + + // Add a session cookie. + await promiseSetCookie("foo=bar"); + ok(cookieExists("example.com", "foo", "bar"), "cookie was added"); + + // Clear all session cookies. + Services.cookies.removeAll(); + ok(!cookieExists("example.com", "foo", "bar"), "cookies were cleared"); + + // Cleanup. + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); diff --git a/browser/components/sessionstore/test/browser_cookies_legacy.js b/browser/components/sessionstore/test/browser_cookies_legacy.js new file mode 100644 index 0000000000..087c2d516f --- /dev/null +++ b/browser/components/sessionstore/test/browser_cookies_legacy.js @@ -0,0 +1,75 @@ +"use strict"; + +function createTestState() { + let r = Math.round(Math.random() * 100000); + + let cookie = { + host: "http://example.com", + path: "/", + name: `name${r}`, + value: `value${r}`, + }; + + let state = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:robots", triggeringPrincipal_base64 }] }, + ], + cookies: [cookie], + }, + ], + }; + + return [state, cookie]; +} + +function waitForNewCookie({ host, name, value }) { + info(`waiting for cookie ${name}=${value} from ${host}...`); + + return new Promise(resolve => { + Services.obs.addObserver(function observer(subj, topic, data) { + if (data != "added") { + return; + } + + let cookie = subj.QueryInterface(Ci.nsICookie); + if (cookie.host == host && cookie.name == name && cookie.value == value) { + ok(true, "cookie added by the cookie service"); + Services.obs.removeObserver(observer, topic); + resolve(); + } + }, "session-cookie-changed"); + }); +} + +// Setup and cleanup. +add_task(async function test_setup() { + Services.cookies.removeAll(); + + registerCleanupFunction(() => { + Services.cookies.removeAll(); + }); +}); + +// Test that calling ss.setWindowState() with a legacy state object that +// contains cookies in the window state restores session cookies properly. +add_task(async function test_window() { + let [state, cookie] = createTestState(); + let win = await promiseNewWindowLoaded(); + + let promiseCookie = waitForNewCookie(cookie); + ss.setWindowState(win, JSON.stringify(state), true); + await promiseCookie; + + await BrowserTestUtils.closeWindow(win); +}); + +// Test that calling ss.setBrowserState() with a legacy state object that +// contains cookies in the window state restores session cookies properly. +add_task(async function test_browser() { + let backupState = ss.getBrowserState(); + let [state, cookie] = createTestState(); + await Promise.all([waitForNewCookie(cookie), promiseBrowserState(state)]); + await promiseBrowserState(backupState); +}); diff --git a/browser/components/sessionstore/test/browser_cookies_privacy.js b/browser/components/sessionstore/test/browser_cookies_privacy.js new file mode 100644 index 0000000000..2c588c8a49 --- /dev/null +++ b/browser/components/sessionstore/test/browser_cookies_privacy.js @@ -0,0 +1,125 @@ +"use strict"; + +// MAX_EXPIRY should be 2^63-1, but JavaScript can't handle that precision. +const MAX_EXPIRY = Math.pow(2, 62); + +function addCookie(scheme, secure = false) { + let cookie = createTestCookie(scheme, secure); + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + cookie.value, + cookie.secure, + /* isHttpOnly = */ false, + /* isSession = */ true, + MAX_EXPIRY, + /* originAttributes = */ {}, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTPS + ); + return cookie; +} + +function createTestCookie(scheme, secure = false) { + let r = Math.round(Math.random() * 100000); + + let cookie = { + host: `${scheme}://example.com`, + path: "/", + name: `name${r}`, + value: `value${r}`, + secure, + }; + + return cookie; +} + +function getCookie() { + let state = JSON.parse(ss.getBrowserState()); + let cookies = state.cookies || []; + return cookies[0]; +} + +function compareCookies(a) { + let b = getCookie(); + return a.host == b.host && a.name == b.name && a.value == b.value; +} + +// Setup and cleanup. +add_task(async function test_setup() { + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); + Services.cookies.removeAll(); + }); +}); + +// Test privacy_level=none (default). We store all session cookies. +add_task(async function test_level_none() { + Services.cookies.removeAll(); + + // Set level=none, store all cookies. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 0); + + // With the default privacy level we collect all cookies. + ok(compareCookies(addCookie("http")), "non-secure http cookie stored"); + Services.cookies.removeAll(); + + // With the default privacy level we collect all cookies. + ok(compareCookies(addCookie("https")), "non-secure https cookie stored"); + Services.cookies.removeAll(); + + // With the default privacy level we collect all cookies. + ok(compareCookies(addCookie("https", true)), "secure https cookie stored"); + Services.cookies.removeAll(); +}); + +// Test privacy_level=encrypted. We store all non-secure session cookies. +add_task(async function test_level_encrypted() { + Services.cookies.removeAll(); + + // Set level=encrypted, don't store any secure cookies. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 1); + + // With level=encrypted, non-secure cookies will be stored. + ok(compareCookies(addCookie("http")), "non-secure http cookie stored"); + Services.cookies.removeAll(); + + // With level=encrypted, non-secure cookies will be stored, + // even if sent by an HTTPS site. + ok(compareCookies(addCookie("https")), "non-secure https cookie stored"); + Services.cookies.removeAll(); + + // With level=encrypted, non-secure cookies will be stored, + // even if sent by an HTTPS site. + ok( + addCookie("https", true) && !getCookie(), + "secure https cookie not stored" + ); + Services.cookies.removeAll(); +}); + +// Test privacy_level=full. We store no session cookies. +add_task(async function test_level_full() { + Services.cookies.removeAll(); + + // Set level=full, don't store any cookies. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 2); + + // With level=full we must not store any cookies. + ok(addCookie("http") && !getCookie(), "non-secure http cookie not stored"); + Services.cookies.removeAll(); + + // With level=full we must not store any cookies. + ok(addCookie("https") && !getCookie(), "non-secure https cookie not stored"); + Services.cookies.removeAll(); + + // With level=full we must not store any cookies. + ok( + addCookie("https", true) && !getCookie(), + "secure https cookie not stored" + ); + Services.cookies.removeAll(); +}); diff --git a/browser/components/sessionstore/test/browser_cookies_sameSite.js b/browser/components/sessionstore/test/browser_cookies_sameSite.js new file mode 100644 index 0000000000..bc680dac25 --- /dev/null +++ b/browser/components/sessionstore/test/browser_cookies_sameSite.js @@ -0,0 +1,89 @@ +"use strict"; + +const TEST_HTTP_URL = "http://example.com"; +const TEST_HTTPS_URL = "https://example.com"; +const MAX_EXPIRY = Math.pow(2, 62); + +function getSingleCookie() { + let cookies = Array.from(Services.cookies.cookies); + Assert.equal(cookies.length, 1, "expected one cookie"); + return cookies[0]; +} + +async function verifyRestore(url, sameSiteSetting) { + Services.cookies.removeAll(); + + // Make sure that sessionstore.js can be forced to be created by setting + // the interval pref to 0. + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.interval", 0]], + }); + + let tab = BrowserTestUtils.addTab(gBrowser, url); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + // Add a cookie with specific same-site setting. + let r = Math.floor(Math.random() * MAX_EXPIRY); + Services.cookies.add( + url, + "/", + "name" + r, + "value" + r, + false, + false, + true, + MAX_EXPIRY, + {}, + sameSiteSetting, + url.startsWith("https:") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + await TabStateFlusher.flush(tab.linkedBrowser); + + // Get the sessionstore state for the window. + let state = ss.getBrowserState(); + + // Verify our cookie got set. + let cookie = getSingleCookie(); + + // Remove the cookie. + Services.cookies.removeAll(); + + // Restore the window state. + await setBrowserState(state); + + // At this point, the cookie should be restored. + let cookie2 = getSingleCookie(); + + is( + cookie2.sameSite, + cookie.sameSite, + "cookie same-site flag successfully restored" + ); + + is( + cookie2.schemeMap, + cookie.schemeMap, + "cookie schemeMap flag successfully restored" + ); + + // Clean up. + Services.cookies.removeAll(); + BrowserTestUtils.removeTab(gBrowser.tabs[1]); +} + +/** + * Tests that cookie.sameSite flag is stored and restored correctly by + * sessionstore. + */ +add_task(async function() { + // Test for various possible values of cookie.sameSite and schemeMap. + await verifyRestore(TEST_HTTP_URL, Ci.nsICookie.SAMESITE_NONE); + await verifyRestore(TEST_HTTP_URL, Ci.nsICookie.SAMESITE_LAX); + await verifyRestore(TEST_HTTP_URL, Ci.nsICookie.SAMESITE_STRICT); + + await verifyRestore(TEST_HTTPS_URL, Ci.nsICookie.SAMESITE_NONE); + await verifyRestore(TEST_HTTPS_URL, Ci.nsICookie.SAMESITE_LAX); + await verifyRestore(TEST_HTTPS_URL, Ci.nsICookie.SAMESITE_STRICT); +}); diff --git a/browser/components/sessionstore/test/browser_crashedTabs.js b/browser/components/sessionstore/test/browser_crashedTabs.js new file mode 100644 index 0000000000..1e136e3184 --- /dev/null +++ b/browser/components/sessionstore/test/browser_crashedTabs.js @@ -0,0 +1,501 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This file spawns content tasks. + +"use strict"; + +requestLongerTimeout(10); + +const PAGE_1 = + "data:text/html,<html><body>A%20regular,%20everyday,%20normal%20page."; +const PAGE_2 = + "data:text/html,<html><body>Another%20regular,%20everyday,%20normal%20page."; + +// Turn off tab animations for testing and use a single content process +// for these tests since we want to test tabs within the crashing process here. +gReduceMotionOverride = true; + +// Allow tabs to restore on demand so we can test pending states +Services.prefs.clearUserPref("browser.sessionstore.restore_on_demand"); + +function clickButton(browser, id) { + info("Clicking " + id); + return SpecialPowers.spawn(browser, [id], buttonId => { + let button = content.document.getElementById(buttonId); + button.click(); + }); +} + +/** + * Checks the documentURI of the root document of a remote browser + * to see if it equals URI. + * + * @param browser + * The remote <xul:browser> to check the root document URI in. + * @param URI + * A string to match the root document URI against. + * @return Promise + */ +async function promiseContentDocumentURIEquals(browser, URI) { + let contentURI = await SpecialPowers.spawn(browser, [], () => { + return content.document.documentURI; + }); + is( + contentURI, + URI, + `Content has URI ${contentURI} which does not match ${URI}` + ); +} + +/** + * Checks the window.history.length of the root window of a remote + * browser to see if it equals length. + * + * @param browser + * The remote <xul:browser> to check the root window.history.length + * @param length + * The expected history length + * @return Promise + */ +async function promiseHistoryLength(browser, length) { + let contentLength = await SpecialPowers.spawn(browser, [], () => { + return content.history.length; + }); + is( + contentLength, + length, + `Content has window.history.length ${contentLength} which does ` + + `not equal expected ${length}` + ); +} + +/** + * Returns a Promise that resolves when a browser has fired the + * AboutTabCrashedReady event. + * + * @param browser + * The remote <xul:browser> that will fire the event. + * @return Promise + */ +function promiseTabCrashedReady(browser) { + return new Promise(resolve => { + browser.addEventListener( + "AboutTabCrashedReady", + function ready(e) { + browser.removeEventListener("AboutTabCrashedReady", ready, false, true); + resolve(); + }, + false, + true + ); + }); +} + +/** + * Checks that if a tab crashes, that information about the tab crashed + * page does not get added to the tab history. + */ +add_task(async function test_crash_page_not_in_history() { + let newTab = BrowserTestUtils.addTab(gBrowser); + gBrowser.selectedTab = newTab; + let browser = newTab.linkedBrowser; + ok(browser.isRemoteBrowser, "Should be a remote browser"); + await promiseBrowserLoaded(browser); + + BrowserTestUtils.loadURI(browser, PAGE_1); + await promiseBrowserLoaded(browser); + await TabStateFlusher.flush(browser); + + // Crash the tab + await BrowserTestUtils.crashFrame(browser); + + // Check the tab state and make sure the tab crashed page isn't + // mentioned. + let { entries } = JSON.parse(ss.getTabState(newTab)); + is(entries.length, 1, "Should have a single history entry"); + is( + entries[0].url, + PAGE_1, + "Single entry should be the page we visited before crashing" + ); + + gBrowser.removeTab(newTab); +}); + +/** + * Checks that if a tab crashes, that when we browse away from that page + * to a non-blacklisted site (so the browser becomes remote again), that + * we record history for that new visit. + */ +add_task(async function test_revived_history_from_remote() { + let newTab = BrowserTestUtils.addTab(gBrowser); + gBrowser.selectedTab = newTab; + let browser = newTab.linkedBrowser; + ok(browser.isRemoteBrowser, "Should be a remote browser"); + await promiseBrowserLoaded(browser); + + BrowserTestUtils.loadURI(browser, PAGE_1); + await promiseBrowserLoaded(browser); + await TabStateFlusher.flush(browser); + + // Crash the tab + await BrowserTestUtils.crashFrame(browser); + + // Browse to a new site that will cause the browser to + // become remote again. + BrowserTestUtils.loadURI(browser, PAGE_2); + await promiseBrowserLoaded(browser); + ok( + !newTab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + ok(browser.isRemoteBrowser, "Should be a remote browser"); + await TabStateFlusher.flush(browser); + + // Check the tab state and make sure the tab crashed page isn't + // mentioned. + let { entries } = JSON.parse(ss.getTabState(newTab)); + is(entries.length, 2, "Should have two history entries"); + is( + entries[0].url, + PAGE_1, + "First entry should be the page we visited before crashing" + ); + is( + entries[1].url, + PAGE_2, + "Second entry should be the page we visited after crashing" + ); + + gBrowser.removeTab(newTab); +}); + +/** + * Checks that if a tab crashes, that when we browse away from that page + * to a blacklisted site (so the browser stays non-remote), that + * we record history for that new visit. + */ +add_task(async function test_revived_history_from_non_remote() { + let newTab = BrowserTestUtils.addTab(gBrowser); + gBrowser.selectedTab = newTab; + let browser = newTab.linkedBrowser; + ok(browser.isRemoteBrowser, "Should be a remote browser"); + await promiseBrowserLoaded(browser); + + BrowserTestUtils.loadURI(browser, PAGE_1); + await promiseBrowserLoaded(browser); + await TabStateFlusher.flush(browser); + + // Crash the tab + await BrowserTestUtils.crashFrame(browser); + + // Browse to a new site that will not cause the browser to + // become remote again. + BrowserTestUtils.loadURI(browser, "about:mozilla"); + await promiseBrowserLoaded(browser); + ok( + !newTab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + ok(!browser.isRemoteBrowser, "Should not be a remote browser"); + await TabStateFlusher.flush(browser); + + // Check the tab state and make sure the tab crashed page isn't + // mentioned. + let { entries } = JSON.parse(ss.getTabState(newTab)); + is(entries.length, 2, "Should have two history entries"); + is( + entries[0].url, + PAGE_1, + "First entry should be the page we visited before crashing" + ); + is( + entries[1].url, + "about:mozilla", + "Second entry should be the page we visited after crashing" + ); + + gBrowser.removeTab(newTab); +}); + +/** + * Checks that we can revive a crashed tab back to the page that + * it was on when it crashed. + */ +add_task(async function test_revive_tab_from_session_store() { + let newTab = BrowserTestUtils.addTab(gBrowser); + gBrowser.selectedTab = newTab; + let browser = newTab.linkedBrowser; + ok(browser.isRemoteBrowser, "Should be a remote browser"); + await promiseBrowserLoaded(browser); + + BrowserTestUtils.loadURI(browser, PAGE_1); + await promiseBrowserLoaded(browser); + + let newTab2 = BrowserTestUtils.addTab(gBrowser, "about:blank", { + remoteType: browser.remoteType, + initialBrowsingContextGroupId: browser.browsingContext.group.id, + }); + let browser2 = newTab2.linkedBrowser; + ok(browser2.isRemoteBrowser, "Should be a remote browser"); + await promiseBrowserLoaded(browser2); + + BrowserTestUtils.loadURI(browser, PAGE_1); + await promiseBrowserLoaded(browser); + + BrowserTestUtils.loadURI(browser, PAGE_2); + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + + // Crash the tab + await BrowserTestUtils.crashFrame(browser); + // Background tabs should not be crashed, but should be in the "to be restored" + // state. + ok(!newTab2.hasAttribute("crashed"), "Second tab should not be crashed."); + ok(newTab2.hasAttribute("pending"), "Second tab should be pending."); + + // Use SessionStore to revive the first tab + let tabRestoredPromise = promiseTabRestored(newTab); + await clickButton(browser, "restoreTab"); + await tabRestoredPromise; + ok( + !newTab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + ok(newTab2.hasAttribute("pending"), "Second tab should still be pending."); + + // We can't just check browser.currentURI.spec, because from + // the outside, a crashed tab has the same URI as the page + // it crashed on (much like an about:neterror page). Instead, + // we have to use the documentURI on the content. + await promiseContentDocumentURIEquals(browser, PAGE_2); + + // We should also have two entries in the browser history. + await promiseHistoryLength(browser, 2); + + gBrowser.removeTab(newTab); + gBrowser.removeTab(newTab2); +}); + +/** + * Checks that we can revive multiple crashed tabs back to the pages + * that they were on when they crashed. + */ +add_task(async function test_revive_all_tabs_from_session_store() { + let newTab = BrowserTestUtils.addTab(gBrowser); + gBrowser.selectedTab = newTab; + let browser = newTab.linkedBrowser; + ok(browser.isRemoteBrowser, "Should be a remote browser"); + await promiseBrowserLoaded(browser); + + BrowserTestUtils.loadURI(browser, PAGE_1); + await promiseBrowserLoaded(browser); + + // In order to see a second about:tabcrashed page, we'll need + // a second window, since only selected tabs will show + // about:tabcrashed. + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + let newTab2 = BrowserTestUtils.addTab(win2.gBrowser, PAGE_1, { + remoteType: browser.remoteType, + initialBrowsingContextGroupId: browser.browsingContext.group.id, + }); + win2.gBrowser.selectedTab = newTab2; + let browser2 = newTab2.linkedBrowser; + ok(browser2.isRemoteBrowser, "Should be a remote browser"); + await promiseBrowserLoaded(browser2); + + BrowserTestUtils.loadURI(browser, PAGE_1); + await promiseBrowserLoaded(browser); + + BrowserTestUtils.loadURI(browser, PAGE_2); + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + await TabStateFlusher.flush(browser2); + + // Crash the tab + await BrowserTestUtils.crashFrame(browser); + // Both tabs should now be crashed. + is(newTab.getAttribute("crashed"), "true", "First tab should be crashed"); + is( + newTab2.getAttribute("crashed"), + "true", + "Second window tab should be crashed" + ); + + // Use SessionStore to revive all the tabs + let tabRestoredPromises = Promise.all([ + promiseTabRestored(newTab), + promiseTabRestored(newTab2), + ]); + await clickButton(browser, "restoreAll"); + await tabRestoredPromises; + + ok( + !newTab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + ok(!newTab.hasAttribute("pending"), "Tab shouldn't be pending."); + ok( + !newTab2.hasAttribute("crashed"), + "Second tab shouldn't be marked as crashed anymore." + ); + ok(!newTab2.hasAttribute("pending"), "Second tab shouldn't be pending."); + + // We can't just check browser.currentURI.spec, because from + // the outside, a crashed tab has the same URI as the page + // it crashed on (much like an about:neterror page). Instead, + // we have to use the documentURI on the content. + await promiseContentDocumentURIEquals(browser, PAGE_2); + await promiseContentDocumentURIEquals(browser2, PAGE_1); + + // We should also have two entries in the browser history. + await promiseHistoryLength(browser, 2); + + await BrowserTestUtils.closeWindow(win2); + gBrowser.removeTab(newTab); +}); + +/** + * Checks that about:tabcrashed can close the current tab + */ +add_task(async function test_close_tab_after_crash() { + let newTab = BrowserTestUtils.addTab(gBrowser); + gBrowser.selectedTab = newTab; + let browser = newTab.linkedBrowser; + ok(browser.isRemoteBrowser, "Should be a remote browser"); + await promiseBrowserLoaded(browser); + + BrowserTestUtils.loadURI(browser, PAGE_1); + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + + // Crash the tab + await BrowserTestUtils.crashFrame(browser); + + let promise = BrowserTestUtils.waitForEvent( + gBrowser.tabContainer, + "TabClose" + ); + + // Click the close tab button + await clickButton(browser, "closeTab"); + await promise; + + is(gBrowser.tabs.length, 1, "Should have closed the tab"); +}); + +/** + * Checks that "restore all" button is only shown if more than one tab + * is showing about:tabcrashed + */ +add_task(async function test_hide_restore_all_button() { + let newTab = BrowserTestUtils.addTab(gBrowser); + gBrowser.selectedTab = newTab; + let browser = newTab.linkedBrowser; + ok(browser.isRemoteBrowser, "Should be a remote browser"); + await promiseBrowserLoaded(browser); + + BrowserTestUtils.loadURI(browser, PAGE_1); + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + + // Crash the tab + await BrowserTestUtils.crashFrame(browser); + + let doc = browser.contentDocument; + let restoreAllButton = doc.getElementById("restoreAll"); + let restoreOneButton = doc.getElementById("restoreTab"); + + let restoreAllStyles = window.getComputedStyle(restoreAllButton); + is(restoreAllStyles.display, "none", "Restore All button should be hidden"); + ok( + restoreOneButton.classList.contains("primary"), + "Restore Tab button should have the primary class" + ); + + let newTab2 = BrowserTestUtils.addTab(gBrowser); + gBrowser.selectedTab = newTab; + + BrowserTestUtils.loadURI(browser, PAGE_2); + await promiseBrowserLoaded(browser); + + // Load up a second window so we can get another tab to show + // about:tabcrashed + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + let newTab3 = BrowserTestUtils.addTab(win2.gBrowser, PAGE_2, { + remoteType: browser.remoteType, + initialBrowsingContextGroupId: browser.browsingContext.group.id, + }); + win2.gBrowser.selectedTab = newTab3; + let otherWinBrowser = newTab3.linkedBrowser; + await promiseBrowserLoaded(otherWinBrowser); + // We'll need to make sure the second tab's browser has finished + // sending its AboutTabCrashedReady event before we know for + // sure whether or not we're showing the right Restore buttons. + let otherBrowserReady = promiseTabCrashedReady(otherWinBrowser); + + // Crash the first tab. + await BrowserTestUtils.crashFrame(browser); + await otherBrowserReady; + + doc = browser.contentDocument; + restoreAllButton = doc.getElementById("restoreAll"); + restoreOneButton = doc.getElementById("restoreTab"); + + restoreAllStyles = window.getComputedStyle(restoreAllButton); + isnot( + restoreAllStyles.display, + "none", + "Restore All button should not be hidden" + ); + ok( + !restoreOneButton.classList.contains("primary"), + "Restore Tab button should not have the primary class" + ); + + await BrowserTestUtils.closeWindow(win2); + gBrowser.removeTab(newTab); + gBrowser.removeTab(newTab2); +}); + +add_task(async function test_aboutcrashedtabzoom() { + let newTab = BrowserTestUtils.addTab(gBrowser); + gBrowser.selectedTab = newTab; + let browser = newTab.linkedBrowser; + ok(browser.isRemoteBrowser, "Should be a remote browser"); + await promiseBrowserLoaded(browser); + + BrowserTestUtils.loadURI(browser, PAGE_1); + await promiseBrowserLoaded(browser); + + FullZoom.enlarge(); + let zoomLevel = ZoomManager.getZoomForBrowser(browser); + ok(zoomLevel !== 1, "should have enlarged"); + + await TabStateFlusher.flush(browser); + + // Crash the tab + await BrowserTestUtils.crashFrame(browser); + + ok( + ZoomManager.getZoomForBrowser(browser) === 1, + "zoom should have reset on crash" + ); + + let tabRestoredPromise = promiseTabRestored(newTab); + await clickButton(browser, "restoreTab"); + await tabRestoredPromise; + + ok( + ZoomManager.getZoomForBrowser(browser) === zoomLevel, + "zoom should have gone back to enlarged" + ); + FullZoom.reset(); + + gBrowser.removeTab(newTab); +}); diff --git a/browser/components/sessionstore/test/browser_docshell_uuid_consistency.js b/browser/components/sessionstore/test/browser_docshell_uuid_consistency.js new file mode 100644 index 0000000000..5cebacfe3e --- /dev/null +++ b/browser/components/sessionstore/test/browser_docshell_uuid_consistency.js @@ -0,0 +1,106 @@ +// First test - open a tab and duplicate it, using session restore to restore the history into the new tab. +add_task(async function duplicateTab() { + const TEST_URL = "data:text/html,foo"; + let tab = BrowserTestUtils.addTab(gBrowser, TEST_URL); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + if (!Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(tab.linkedBrowser, [], function() { + let docshell = content.window.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + let shEntry = docshell.sessionHistory.legacySHistory.getEntryAtIndex(0); + is(shEntry.docshellID.toString(), docshell.historyID.toString()); + }); + } else { + let historyID = tab.linkedBrowser.browsingContext.historyID; + let shEntry = tab.linkedBrowser.browsingContext.sessionHistory.getEntryAtIndex( + 0 + ); + is(shEntry.docshellID.toString(), historyID.toString()); + } + + let tab2 = gBrowser.duplicateTab(tab); + await BrowserTestUtils.browserLoaded(tab2.linkedBrowser); + + if (!Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(tab2.linkedBrowser, [], function() { + let docshell = content.window.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + let shEntry = docshell.sessionHistory.legacySHistory.getEntryAtIndex(0); + is(shEntry.docshellID.toString(), docshell.historyID.toString()); + }); + } else { + let historyID = tab2.linkedBrowser.browsingContext.historyID; + let shEntry = tab2.linkedBrowser.browsingContext.sessionHistory.getEntryAtIndex( + 0 + ); + is(shEntry.docshellID.toString(), historyID.toString()); + } + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +// Second test - open a tab and navigate across processes, which triggers sessionrestore to persist history. +add_task(async function contentToChromeNavigate() { + const TEST_URL = "data:text/html,foo"; + let tab = BrowserTestUtils.addTab(gBrowser, TEST_URL); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + if (!Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(tab.linkedBrowser, [], function() { + let docshell = content.window.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + let sh = docshell.sessionHistory; + is(sh.count, 1); + is( + sh.legacySHistory.getEntryAtIndex(0).docshellID.toString(), + docshell.historyID.toString() + ); + }); + } else { + let historyID = tab.linkedBrowser.browsingContext.historyID; + let sh = tab.linkedBrowser.browsingContext.sessionHistory; + is(sh.count, 1); + is(sh.getEntryAtIndex(0).docshellID.toString(), historyID.toString()); + } + + // Force the browser to navigate to the chrome process. + BrowserTestUtils.loadURI(tab.linkedBrowser, "about:config"); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + // Check to be sure that we're in the chrome process. + let docShell = tab.linkedBrowser.frameLoader.docShell; + + // 'cause we're in the chrome process, we can just directly poke at the shistory. + if (!Services.appinfo.sessionHistoryInParent) { + let sh = docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory; + + is(sh.count, 2); + is( + sh.legacySHistory.getEntryAtIndex(0).docshellID.toString(), + docShell.historyID.toString() + ); + is( + sh.legacySHistory.getEntryAtIndex(1).docshellID.toString(), + docShell.historyID.toString() + ); + } else { + let sh = docShell.browsingContext.sessionHistory; + + is(sh.count, 2); + is( + sh.getEntryAtIndex(0).docshellID.toString(), + docShell.historyID.toString() + ); + is( + sh.getEntryAtIndex(1).docshellID.toString(), + docShell.historyID.toString() + ); + } + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_duplicate_history.js b/browser/components/sessionstore/test/browser_duplicate_history.js new file mode 100644 index 0000000000..9e9a386e3b --- /dev/null +++ b/browser/components/sessionstore/test/browser_duplicate_history.js @@ -0,0 +1,29 @@ +ChromeUtils.defineESModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + TabStateCache: "resource:///modules/sessionstore/TabStateCache.sys.mjs", +}); + +add_task(async function() { + await BrowserTestUtils.withNewTab("http://example.com", async function( + aBrowser + ) { + let tab = gBrowser.getTabForBrowser(aBrowser); + await TabStateFlusher.flush(aBrowser); + let before = TabStateCache.get(aBrowser.permanentKey); + + let newTab = SessionStore.duplicateTab(window, tab); + await Promise.all([ + BrowserTestUtils.browserLoaded(newTab.linkedBrowser), + TestUtils.topicObserved("sessionstore-debug-tab-restored"), + ]); + let after = TabStateCache.get(newTab.linkedBrowser.permanentKey); + + isnot( + before.history.entries, + after.history.entries, + "The entry objects should not be shared" + ); + + BrowserTestUtils.removeTab(newTab); + }); +}); diff --git a/browser/components/sessionstore/test/browser_duplicate_tab_in_new_window.js b/browser/components/sessionstore/test/browser_duplicate_tab_in_new_window.js new file mode 100644 index 0000000000..0c77825356 --- /dev/null +++ b/browser/components/sessionstore/test/browser_duplicate_tab_in_new_window.js @@ -0,0 +1,34 @@ +ChromeUtils.defineESModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +add_task(async function() { + await BrowserTestUtils.withNewTab("https://example.com", async function( + aBrowser + ) { + BrowserTestUtils.loadURI(aBrowser, "https://example.org"); + await BrowserTestUtils.browserLoaded(aBrowser); + + let windowOpened = BrowserTestUtils.waitForNewWindow("https://example.org"); + let newWindow = gBrowser.replaceTabWithWindow( + gBrowser.getTabForBrowser(aBrowser) + ); + await windowOpened; + let newTab = SessionStore.duplicateTab( + newWindow, + newWindow.gBrowser.selectedTab + ); + + await BrowserTestUtils.browserLoaded(newTab.linkedBrowser); + + await SpecialPowers.spawn( + newTab.linkedBrowser, + ["https://example.org"], + async ORIGIN => { + is(content.window.origin, ORIGIN); + } + ); + + BrowserTestUtils.closeWindow(newWindow); + }); +}); diff --git a/browser/components/sessionstore/test/browser_dying_cache.js b/browser/components/sessionstore/test/browser_dying_cache.js new file mode 100644 index 0000000000..ad2da69b9d --- /dev/null +++ b/browser/components/sessionstore/test/browser_dying_cache.js @@ -0,0 +1,80 @@ +"use strict"; + +/** + * This test ensures that after closing a window we keep its state data around + * as long as something keeps a reference to it. It should only be possible to + * read data after closing - writing should fail. + */ + +add_task(async function test() { + // Open a new window. + let win = await promiseNewWindowLoaded(); + + // Load some URL in the current tab. + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY; + BrowserTestUtils.loadURI(win.gBrowser.selectedBrowser, "about:robots", { + flags, + }); + await promiseBrowserLoaded(win.gBrowser.selectedBrowser); + + // Open a second tab and close the first one. + let tab = BrowserTestUtils.addTab(win.gBrowser, "about:mozilla"); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + await promiseRemoveTabAndSessionState(win.gBrowser.tabs[0]); + + // Make sure our window is still tracked by sessionstore + // and the window state is as expected. + ok("__SSi" in win, "window is being tracked by sessionstore"); + ss.setCustomWindowValue(win, "foo", "bar"); + checkWindowState(win); + + // Close our window. + await BrowserTestUtils.closeWindow(win); + + // SessionStore should no longer track our window + // but it should still report the same state. + ok(!("__SSi" in win), "sessionstore does no longer track our window"); + checkWindowState(win); + + // Make sure we're not allowed to modify state data. + Assert.throws( + () => ss.setWindowState(win, {}), + /Window is not tracked/, + "we're not allowed to modify state data anymore" + ); + Assert.throws( + () => ss.setCustomWindowValue(win, "foo", "baz"), + /Window is not tracked/, + "we're not allowed to modify state data anymore" + ); +}); + +function checkWindowState(window) { + let { + windows: [{ tabs }], + } = ss.getWindowState(window); + is(tabs.length, 1, "the window has a single tab"); + is(tabs[0].entries[0].url, "about:mozilla", "the tab is about:mozilla"); + + is(ss.getClosedTabCount(window), 1, "the window has one closed tab"); + let [ + { + state: { + entries: [{ url }], + }, + }, + ] = ss.getClosedTabData(window); + is(url, "about:robots", "the closed tab is about:robots"); + + is(ss.getCustomWindowValue(window, "foo"), "bar", "correct extData value"); +} + +function shouldThrow(f) { + try { + f(); + } catch (e) { + return true; + } + return null; +} diff --git a/browser/components/sessionstore/test/browser_dynamic_frames.js b/browser/components/sessionstore/test/browser_dynamic_frames.js new file mode 100644 index 0000000000..7852d683cc --- /dev/null +++ b/browser/components/sessionstore/test/browser_dynamic_frames.js @@ -0,0 +1,105 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Ensure that static frames of framesets are serialized but dynamically + * inserted iframes are ignored. + */ +add_task(async function() { + // allow top level data: URI navigations, otherwise clicking a data: link fails + await SpecialPowers.pushPrefEnv({ + set: [["security.data_uri.block_toplevel_data_uri_navigations", false]], + }); + // This URL has the following frames: + // + data:text/html,A (static) + // + data:text/html,B (static) + // + data:text/html,C (dynamic iframe) + const URL = + "data:text/html;charset=utf-8," + + "<frameset cols=50%25,50%25><frame src='data:text/html,A'>" + + "<frame src='data:text/html,B'></frameset>" + + "<script>var i=document.createElement('iframe');" + + "i.setAttribute('src', 'data:text/html,C');" + + "document.body.appendChild(i);</script>"; + + // Add a new tab with two "static" and one "dynamic" frame. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + + // Check URLs. + ok(entries[0].url.startsWith("data:text/html"), "correct root url"); + is( + entries[0].children[0].url, + "data:text/html,A", + "correct url for 1st frame" + ); + is( + entries[0].children[1].url, + "data:text/html,B", + "correct url for 2nd frame" + ); + + // Check the number of children. + is(entries.length, 1, "there is one root entry ..."); + is(entries[0].children.length, 2, "... with two child entries"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that iframes created by the network parser are serialized but + * dynamically inserted iframes are ignored. Navigating a subframe should + * create a second root entry that doesn't contain any dynamic children either. + */ +add_task(async function() { + // allow top level data: URI navigations, otherwise clicking a data: link fails + await SpecialPowers.pushPrefEnv({ + set: [["security.data_uri.block_toplevel_data_uri_navigations", false]], + }); + // This URL has the following frames: + // + data:text/html,A (static) + // + data:text/html,C (dynamic iframe) + const URL = + "data:text/html;charset=utf-8," + + "<iframe name=t src='data:text/html,A'></iframe>" + + "<a id=lnk href='data:text/html,B' target=t>clickme</a>" + + "<script>var i=document.createElement('iframe');" + + "i.setAttribute('src', 'data:text/html,C');" + + "document.body.appendChild(i);</script>"; + + // Add a new tab with one "static" and one "dynamic" frame. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + + // Check URLs. + ok(entries[0].url.startsWith("data:text/html"), "correct root url"); + ok(!entries[0].children, "no children collected"); + + // Navigate the subframe. + await BrowserTestUtils.synthesizeMouseAtCenter("#lnk", {}, browser); + await promiseBrowserLoaded(browser, false /* don't ignore subframes */); + + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + + // Check URLs. + ok(entries[0].url.startsWith("data:text/html"), "correct 1st root url"); + ok(entries[1].url.startsWith("data:text/html"), "correct 2nd root url"); + ok(!entries.children, "no children collected"); + ok(!entries[0].children, "no children collected"); + ok(!entries[1].children, "no children collected"); + + // Cleanup. + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_firefoxView_restore.js b/browser/components/sessionstore/test/browser_firefoxView_restore.js new file mode 100644 index 0000000000..9af181e0f5 --- /dev/null +++ b/browser/components/sessionstore/test/browser_firefoxView_restore.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const CLOSED_URI = "https://www.example.com/"; + +add_task(async function test_TODO() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, CLOSED_URI); + + Assert.equal(gBrowser.tabs[0].linkedBrowser.currentURI.filePath, "blank"); + + Assert.equal(gBrowser.tabs[1].linkedBrowser.currentURI.spec, CLOSED_URI); + + Assert.ok(gBrowser.selectedTab == tab); + + let state = ss.getCurrentState(true); + + // SessionStore uses one-based indexes + Assert.equal(state.windows[0].selected, 2); + + await EventUtils.synthesizeMouseAtCenter( + window.document.getElementById("firefox-view-button"), + { type: "mousedown" }, + window + ); + Assert.ok(window.FirefoxViewHandler.tab.selected); + + Assert.equal(gBrowser.tabs[2], window.FirefoxViewHandler.tab); + + state = ss.getCurrentState(true); + + // The FxView tab doesn't get recorded in the session state, but if it's the last selected tab when a window is closed + // we want to point to the first tab in the tab strip upon restore + Assert.equal(state.windows[0].selected, 1); + + gBrowser.removeTab(window.FirefoxViewHandler.tab); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_firefoxView_selected_restore.js b/browser/components/sessionstore/test/browser_firefoxView_selected_restore.js new file mode 100644 index 0000000000..690c748c10 --- /dev/null +++ b/browser/components/sessionstore/test/browser_firefoxView_selected_restore.js @@ -0,0 +1,109 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { _LastSession } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" +); +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); + +const state = { + windows: [ + { + tabs: [ + { + entries: [ + { + url: "https://example.org/", + triggeringPrincipal_base64, + }, + ], + }, + ], + selected: 2, + }, + ], +}; + +/** + * This is regrettable, but when `promiseBrowserState` resolves, we're still + * midway through loading the tabs. To avoid race conditions in URLs for tabs + * being available, wait for all the loads to finish: + */ +function promiseSessionStoreLoads(numberOfLoads) { + let loadsSeen = 0; + return new Promise(resolve => { + Services.obs.addObserver(function obs(browser) { + loadsSeen++; + if (loadsSeen == numberOfLoads) { + resolve(); + } + // The typeof check is here to avoid one test messing with everything else by + // keeping the observer indefinitely. + if (typeof info == "undefined" || loadsSeen >= numberOfLoads) { + Services.obs.removeObserver(obs, "sessionstore-debug-tab-restored"); + } + info("Saw load for " + browser.currentURI.spec); + }, "sessionstore-debug-tab-restored"); + }); +} + +add_task(async function test_firefox_view_selected_tab() { + let fxViewBtn = document.getElementById("firefox-view-button"); + ok(fxViewBtn, "Got the Firefox View button"); + fxViewBtn.click(); + + await BrowserTestUtils.browserLoaded( + window.FirefoxViewHandler.tab.linkedBrowser + ); + + let allTabsRestored = promiseSessionStoreLoads(1); + + _LastSession.setState(state); + + is(gBrowser.tabs.length, 2, "Number of tabs is 2"); + + ss.restoreLastSession(); + + await allTabsRestored; + + ok( + window.FirefoxViewHandler.tab.selected, + "The Firefox View tab is selected and the browser window did not close" + ); + is(gBrowser.tabs.length, 3, "Number of tabs is 3"); + + gBrowser.removeTab(window.FirefoxViewHandler.tab); + gBrowser.removeTab(gBrowser.selectedTab); +}); + +add_task(async function test_firefox_view_previously_selected() { + let fxViewBtn = document.getElementById("firefox-view-button"); + ok(fxViewBtn, "Got the Firefox View button"); + fxViewBtn.click(); + + await BrowserTestUtils.browserLoaded( + window.FirefoxViewHandler.tab.linkedBrowser + ); + + let tab = gBrowser.tabs[1]; + gBrowser.selectedTab = tab; + + let allTabsRestored = promiseSessionStoreLoads(1); + + _LastSession.setState(state); + + is(gBrowser.tabs.length, 2, "Number of tabs is 2"); + + ss.restoreLastSession(); + + await allTabsRestored; + + ok( + window.FirefoxViewHandler.tab.selected, + "The Firefox View tab is selected and the browser window did not close" + ); + is(gBrowser.tabs.length, 3, "Number of tabs is 3"); + + gBrowser.removeTab(window.FirefoxViewHandler.tab); + gBrowser.removeTab(gBrowser.selectedTab); +}); diff --git a/browser/components/sessionstore/test/browser_focus_after_restore.js b/browser/components/sessionstore/test/browser_focus_after_restore.js new file mode 100644 index 0000000000..220827657e --- /dev/null +++ b/browser/components/sessionstore/test/browser_focus_after_restore.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function test() { + gURLBar.focus(); + is( + document.activeElement, + gURLBar.inputField, + "urlbar is focused before restoring" + ); + + await promiseBrowserState({ + windows: [ + { + tabs: [ + { + entries: [ + { + url: "http://example.org/", + triggeringPrincipal_base64, + }, + ], + }, + ], + selected: 1, + }, + ], + }); + is( + document.activeElement, + gBrowser.selectedBrowser, + "content area is focused after restoring" + ); +}); diff --git a/browser/components/sessionstore/test/browser_forget_async_closings.js b/browser/components/sessionstore/test/browser_forget_async_closings.js new file mode 100644 index 0000000000..e66ef70b58 --- /dev/null +++ b/browser/components/sessionstore/test/browser_forget_async_closings.js @@ -0,0 +1,159 @@ +"use strict"; + +const PAGE = "http://example.com/"; + +/** + * Creates a tab in the current window worth storing in the + * closedTabs array, and then closes it. Runs a synchronous + * forgetFn passed in that should cause us to forget the tab, + * and then ensures that after the tab has sent its final + * update message that we didn't accidentally store it in + * the closedTabs array. + * + * @param forgetFn (function) + * A synchronous function that should cause the tab + * to be forgotten. + * @returns Promise + */ +let forgetTabHelper = async function(forgetFn) { + // We want to suppress all non-final updates from the browser tabs + // so as to eliminate any racy-ness with this test. + await pushPrefs(["browser.sessionstore.debug.no_auto_updates", true]); + + // Forget any previous closed tabs from other tests that may have + // run in the same session. + Services.obs.notifyObservers(null, "browser:purge-session-history"); + + is( + ss.getClosedTabCount(window), + 0, + "We should have 0 closed tabs being stored." + ); + + // Create a tab worth remembering. + let tab = BrowserTestUtils.addTab(gBrowser, PAGE); + let browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser, false, PAGE); + await TabStateFlusher.flush(browser); + + // Now close the tab, and immediately choose to forget it. + let promise = promiseRemoveTabAndSessionState(tab); + + // At this point, the tab will have closed, but the final update + // to SessionStore hasn't come up yet. Now do the operation that + // should cause us to forget the tab. + forgetFn(); + + is(ss.getClosedTabCount(window), 0, "Should have forgotten the closed tab"); + + // Now wait for the final update to come up. + await promise; + + is( + ss.getClosedTabCount(window), + 0, + "Should not have stored the forgotten closed tab" + ); +}; + +/** + * Creates a new window worth storing in the closeWIndows array, + * and then closes it. Runs a synchronous forgetFn passed in that + * should cause us to forget the window, and then ensures that after + * the window has sent its final update message that we didn't + * accidentally store it in the closedWindows array. + * + * @param forgetFn (function) + * A synchronous function that should cause the window + * to be forgotten. + * @returns Promise + */ +let forgetWinHelper = async function(forgetFn) { + // We want to suppress all non-final updates from the browser tabs + // so as to eliminate any racy-ness with this test. + await pushPrefs(["browser.sessionstore.debug.no_auto_updates", true]); + + // Forget any previous closed windows from other tests that may have + // run in the same session. + Services.obs.notifyObservers(null, "browser:purge-session-history"); + + is( + ss.getClosedWindowCount(), + 0, + "We should have 0 closed windows being stored." + ); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + + // Create a tab worth remembering. + let tab = newWin.gBrowser.selectedTab; + let browser = tab.linkedBrowser; + BrowserTestUtils.loadURI(browser, PAGE); + await BrowserTestUtils.browserLoaded(browser, false, PAGE); + await TabStateFlusher.flush(browser); + + // Now close the window and immediately choose to forget it. + let windowClosed = BrowserTestUtils.windowClosed(newWin); + + let handled = false; + whenDomWindowClosedHandled(() => { + // At this point, the window will have closed and the onClose handler + // has run, but the final update to SessionStore hasn't come up yet. + // Now do the oepration that should cause us to forget the window. + forgetFn(); + + is(ss.getClosedWindowCount(), 0, "Should have forgotten the closed window"); + + handled = true; + }); + + newWin.close(); + + // Now wait for the final update to come up. + await windowClosed; + + ok(handled, "domwindowclosed should already be handled here"); + + is(ss.getClosedWindowCount(), 0, "Should not have stored the closed window"); +}; + +/** + * Tests that if we choose to forget a tab while waiting for its + * final flush to complete, we don't accidentally store it. + */ +add_task(async function test_forget_closed_tab() { + await forgetTabHelper(() => { + ss.forgetClosedTab(window, 0); + }); +}); + +/** + * Tests that if we choose to forget a tab while waiting for its + * final flush to complete, we don't accidentally store it. + */ +add_task(async function test_forget_closed_window() { + await forgetWinHelper(() => { + ss.forgetClosedWindow(0); + }); +}); + +/** + * Tests that if we choose to purge history while waiting for a + * final flush of a tab to complete, we don't accidentally store it. + */ +add_task(async function test_forget_purged_tab() { + await forgetTabHelper(() => { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + }); +}); + +/** + * Tests that if we choose to purge history while waiting for a + * final flush of a window to complete, we don't accidentally + * store it. + */ +add_task(async function test_forget_purged_window() { + await forgetWinHelper(() => { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + }); +}); diff --git a/browser/components/sessionstore/test/browser_formdata.js b/browser/components/sessionstore/test/browser_formdata.js new file mode 100644 index 0000000000..b265fd0f25 --- /dev/null +++ b/browser/components/sessionstore/test/browser_formdata.js @@ -0,0 +1,227 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +/** + * This test ensures that form data collection respects the privacy level as + * set by the user. + */ +add_task(async function test_formdata() { + const URL = + "http://mochi.test:8888/browser/browser/components/" + + "sessionstore/test/browser_formdata_sample.html"; + + const OUTER_VALUE = "browser_formdata_" + Math.random(); + const INNER_VALUE = "browser_formdata_" + Math.random(); + + // Creates a tab, loads a page with some form fields, + // modifies their values and closes the tab. + async function createAndRemoveTab() { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Modify form data. + await setPropertyOfFormField(browser, "#txt", "value", OUTER_VALUE); + await setPropertyOfFormField( + browser.browsingContext.children[0], + "#txt", + "value", + INNER_VALUE + ); + + // Remove the tab. + await promiseRemoveTabAndSessionState(tab); + } + + await createAndRemoveTab(); + let [ + { + state: { formdata }, + }, + ] = ss.getClosedTabData(window); + is(formdata.id.txt, OUTER_VALUE, "outer value is correct"); + is(formdata.children[0].id.txt, INNER_VALUE, "inner value is correct"); + + // Disable saving data for encrypted sites. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 1); + + await createAndRemoveTab(); + [ + { + state: { formdata }, + }, + ] = ss.getClosedTabData(window); + is(formdata.id.txt, OUTER_VALUE, "outer value is correct"); + ok(!formdata.children, "inner value was *not* stored"); + + // Disable saving data for any site. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 2); + + await createAndRemoveTab(); + [ + { + state: { formdata }, + }, + ] = ss.getClosedTabData(window); + ok(!formdata, "form data has *not* been stored"); + + // Restore the default privacy level. + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); +}); + +/** + * This test ensures that a malicious website can't trick us into restoring + * form data into a wrong website and that we always check the stored URL + * before doing so. + */ +add_task(async function test_url_check() { + const URL = "data:text/html;charset=utf-8,<input id=input>"; + const VALUE = "value-" + Math.random(); + + // Create a tab with an iframe containing an input field. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Restore a tab state with a given form data url. + async function restoreStateWithURL(url) { + let state = { + entries: [{ url: URL, triggeringPrincipal_base64 }], + formdata: { id: { input: VALUE } }, + }; + + if (url) { + state.formdata.url = url; + } + + await promiseTabState(tab, state); + return getPropertyOfFormField(browser, "#input", "value"); + } + + // Check that the form value is restored with the correct URL. + is(await restoreStateWithURL(URL), VALUE, "form data restored"); + + // Check that the form value is *not* restored with the wrong URL. + is(await restoreStateWithURL(URL + "?"), "", "form data not restored"); + is(await restoreStateWithURL(), "", "form data not restored"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * This test ensures that collecting form data works as expected when having + * nested frame sets. + */ +add_task(async function test_nested() { + const URL = + "data:text/html;charset=utf-8," + + "<iframe src='data:text/html;charset=utf-8,<input/>'/>"; + + const FORM_DATA = { + children: [ + { + url: "data:text/html;charset=utf-8,<input/>", + xpath: { "/xhtml:html/xhtml:body/xhtml:input": "m" }, + }, + ], + }; + + // Create a tab with an iframe containing an input field. + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, URL)); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser, false /* don't ignore subframes */); + + const iframe = await SpecialPowers.spawn(browser, [], () => { + return content.document.querySelector("iframe").browsingContext; + }); + await SpecialPowers.spawn(iframe, [], async () => { + const input = content.document.querySelector("input"); + const focusPromise = new Promise(resolve => { + input.addEventListener("focus", resolve, { once: true }); + }); + input.focus(); + await focusPromise; + }); + + // Modify the input field's value. + await BrowserTestUtils.synthesizeKey("m", {}, browser); + + // Remove the tab and check that we stored form data correctly. + await promiseRemoveTabAndSessionState(tab); + let [ + { + state: { formdata }, + }, + ] = ss.getClosedTabData(window); + is( + JSON.stringify(formdata), + JSON.stringify(FORM_DATA), + "formdata for iframe stored correctly" + ); + + // Restore the closed tab. + tab = ss.undoCloseTab(window, 0); + browser = tab.linkedBrowser; + await promiseTabRestored(tab); + + // Check that the input field has the right value. + await TabStateFlusher.flush(browser); + ({ formdata } = JSON.parse(ss.getTabState(tab))); + is( + JSON.stringify(formdata), + JSON.stringify(FORM_DATA), + "formdata for iframe restored correctly" + ); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * This test ensures that collecting form data for documents with + * designMode=on works as expected. + */ +add_task(async function test_design_mode() { + const URL = + "data:text/html;charset=utf-8,<h1>mozilla</h1>" + + "<script>document.designMode='on'</script>"; + + // Load a tab with an editable document. + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, URL)); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Modify the document content. + await BrowserTestUtils.synthesizeKey("m", {}, browser); + + // Close and restore the tab. + await promiseRemoveTabAndSessionState(tab); + tab = ss.undoCloseTab(window, 0); + browser = tab.linkedBrowser; + await promiseTabRestored(tab); + + // Check that the innerHTML value was restored. + let html = await getPropertyOfFormField(browser, "body", "innerHTML"); + let expected = "<h1>mmozilla</h1><script>document.designMode='on'</script>"; + is(html, expected, "editable document has been restored correctly"); + + // Close and restore the tab. + await promiseRemoveTabAndSessionState(tab); + tab = ss.undoCloseTab(window, 0); + browser = tab.linkedBrowser; + await promiseTabRestored(tab); + + // Check that the innerHTML value was restored. + html = await getPropertyOfFormField(browser, "body", "innerHTML"); + expected = "<h1>mmozilla</h1><script>document.designMode='on'</script>"; + is(html, expected, "editable document has been restored correctly"); + + // Cleanup. + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_formdata_cc.js b/browser/components/sessionstore/test/browser_formdata_cc.js new file mode 100644 index 0000000000..ae8fb2984b --- /dev/null +++ b/browser/components/sessionstore/test/browser_formdata_cc.js @@ -0,0 +1,107 @@ +"use strict"; + +const URL = + "http://mochi.test:8888/browser/browser/components/" + + "sessionstore/test/browser_formdata_sample.html"; + +requestLongerTimeout(3); + +/** + * This test ensures that credit card numbers in form data will not be + * collected, while numbers that don't look like credit card numbers will + * still be collected. + */ +add_task(async function() { + const validCCNumbers = [ + // 15 digits + "930771457288760", + "474915027480942", + "924894781317325", + "714816113937185", + "790466087343106", + "474320195408363", + "219211148122351", + "633038472250799", + "354236732906484", + "095347810189325", + // 16 digits + "3091269135815020", + "5471839082338112", + "0580828863575793", + "5015290610002932", + "9465714503078607", + "4302068493801686", + "2721398408985465", + "6160334316984331", + "8643619970075142", + "0218246069710785", + ]; + + const invalidCCNumbers = [ + // 15 digits + "526931005800649", + "724952425140686", + "379761391174135", + "030551436468583", + "947377014076746", + "254848023655752", + "226871580283345", + "708025346034339", + "917585839076788", + "918632588027666", + // 16 digits + "9946177098017064", + "4081194386488872", + "3095975979578034", + "3662215692222536", + "6723210018630429", + "4411962856225025", + "8276996369036686", + "4449796938248871", + "3350852696538147", + "5011802870046957", + ]; + + // Creates a tab, loads a page with a form field, sets the value of the + // field, and then removes the tab to trigger data collection. + async function createAndRemoveTab(formValue) { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Set form value. + await setInputValue(browser, formValue); + + // Remove the tab. + await promiseRemoveTabAndSessionState(tab); + } + + // Test that valid CC numbers are not collected. + for (let number of validCCNumbers) { + await createAndRemoveTab(number); + let [{ state }] = ss.getClosedTabData(window); + ok(!("formdata" in state), "valid CC numbers are not collected"); + } + + // Test that non-CC numbers are still collected. + for (let number of invalidCCNumbers) { + await createAndRemoveTab(number); + let [ + { + state: { formdata }, + }, + ] = ss.getClosedTabData(window); + is( + formdata.id.txt, + number, + "numbers that are not valid CC numbers are still collected" + ); + } +}); + +function setInputValue(browser, formValue) { + return SpecialPowers.spawn(browser, [formValue], async function(newValue) { + content.document.getElementById("txt").setUserInput(newValue); + }); +} diff --git a/browser/components/sessionstore/test/browser_formdata_format.js b/browser/components/sessionstore/test/browser_formdata_format.js new file mode 100644 index 0000000000..d4ac1f6e1b --- /dev/null +++ b/browser/components/sessionstore/test/browser_formdata_format.js @@ -0,0 +1,170 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function test() { + /** Tests formdata format **/ + waitForExplicitFinish(); + + let formData = [ + {}, + // old format + { "#input1": "value0" }, + { + "#input1": "value1", + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value2", + }, + { "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value3" }, + // new format + { id: { input1: "value4" } }, + { id: { input1: "value5" }, xpath: {} }, + { + id: { input1: "value6" }, + xpath: { "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value7" }, + }, + { + id: {}, + xpath: { "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value8" }, + }, + { + xpath: { "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value9" }, + }, + // combinations + { "#input1": "value10", id: { input1: "value11" } }, + { + "#input1": "value12", + id: { input1: "value13" }, + xpath: { + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value14", + }, + }, + { + "#input1": "value15", + xpath: { + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value16", + }, + }, + { + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value17", + id: { input1: "value18" }, + }, + { + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value19", + id: { input1: "value20" }, + xpath: { + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value21", + }, + }, + { + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value22", + xpath: { + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value23", + }, + }, + { + "#input1": "value24", + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value25", + id: { input1: "value26" }, + }, + { + "#input1": "value27", + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value28", + id: { input1: "value29" }, + xpath: { + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value30", + }, + }, + { + "#input1": "value31", + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value32", + xpath: { + "/xhtml:html/xhtml:body/xhtml:input[@name='input2']": "value33", + }, + }, + ]; + let expectedValues = [ + ["", ""], + // old format + ["value0", ""], + ["value1", "value2"], + ["", "value3"], + // new format + ["value4", ""], + ["value5", ""], + ["value6", "value7"], + ["", "value8"], + ["", "value9"], + // combinations + ["value11", ""], + ["value13", "value14"], + ["", "value16"], + ["value18", ""], + ["value20", "value21"], + ["", "value23"], + ["value26", ""], + ["value29", "value30"], + ["", "value33"], + ]; + + let promises = []; + for (let i = 0; i < formData.length; i++) { + promises.push(testTabRestoreData(formData[i], expectedValues[i])); + } + + Promise.all(promises).then( + () => finish(), + ex => ok(false, ex) + ); +} + +async function testTabRestoreData(aFormData, aExpectedValue) { + let URL = ROOT + "browser_formdata_format_sample.html"; + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + + aFormData.url = URL; + let tabState = { + entries: [{ url: URL, triggeringPrincipal_base64 }], + formdata: aFormData, + }; + + await promiseBrowserLoaded(tab.linkedBrowser); + await promiseTabState(tab, tabState); + + await TabStateFlusher.flush(tab.linkedBrowser); + let restoredTabState = JSON.parse(ss.getTabState(tab)); + let restoredFormData = restoredTabState.formdata; + + if (restoredFormData) { + let doc = tab.linkedBrowser.contentDocument; + let input1 = doc.getElementById("input1"); + let input2 = doc.querySelector("input[name=input2]"); + + // test format + ok( + "id" in restoredFormData || "xpath" in restoredFormData, + "FormData format is valid: " + restoredFormData + ); + // validate that there are no old keys + for (let key of Object.keys(restoredFormData)) { + if (!["id", "xpath", "url"].includes(key)) { + ok(false, "FormData format is invalid."); + } + } + // test id + is( + input1.value, + aExpectedValue[0], + "FormData by 'id' has been restored correctly" + ); + // test xpath + is( + input2.value, + aExpectedValue[1], + "FormData by 'xpath' has been restored correctly" + ); + } + + // clean up + gBrowser.removeTab(tab); +} diff --git a/browser/components/sessionstore/test/browser_formdata_format_sample.html b/browser/components/sessionstore/test/browser_formdata_format_sample.html new file mode 100644 index 0000000000..ed71702a1b --- /dev/null +++ b/browser/components/sessionstore/test/browser_formdata_format_sample.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> +<title>Test formdata format</title> + +<!-- input events --> +<h3>Input fields</h3> +<input type="text" id="input1"> +<input type="text" name="input2"> diff --git a/browser/components/sessionstore/test/browser_formdata_max_size.js b/browser/components/sessionstore/test/browser_formdata_max_size.js new file mode 100644 index 0000000000..00b49985e3 --- /dev/null +++ b/browser/components/sessionstore/test/browser_formdata_max_size.js @@ -0,0 +1,131 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = + "http://mochi.test:8888/browser/browser/components/" + + "sessionstore/test/browser_formdata_sample.html"; + +const SHORT_VALUE = "abc"; +const LONG_VALUE = "abcdef"; + +add_task(async function test_form_limit() { + await SpecialPowers.pushPrefEnv({ + set: [ + // "browser.sessionstore.dom_form_limit" limits the length of values in + // forms to 5. Here we have that SHORT_VALUE is less than 5 and + // LONG_VALUE is greater than 5. + ["browser.sessionstore.dom_form_limit", 5], + ["browser.sessionstore.debug.no_auto_updates", true], + ], + }); + + await BrowserTestUtils.withNewTab({ gBrowser, url: URL }, async browser => { + await setPropertyOfFormField(browser, "#txt", "value", SHORT_VALUE); + await TabStateFlusher.flush(browser); + + let tab = gBrowser.getTabForBrowser(browser); + let state = JSON.parse(ss.getTabState(tab)); + is( + state.formdata.id.txt, + SHORT_VALUE, + "values shorter than browser.sessionstore.dom_form_limit is ok." + ); + + await setPropertyOfFormField(browser, "#txt", "value", LONG_VALUE); + await TabStateFlusher.flush(browser); + + state = JSON.parse(ss.getTabState(tab)); + ok( + !state?.formdata?.id?.txt, + "values shorter than browser.sessionstore.dom_form_limit isn't ok." + ); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_form_max_limit() { + await SpecialPowers.pushPrefEnv({ + set: [ + // "browser.sessionstore.dom_form_max_limit" limits the total length + // of values AND length id/xpath collected from a form. Here we have + // that SHORT_VALUE + 'txt' is less than 7 and LONG_VALUE + 'txt' is + // greater than 7. + ["browser.sessionstore.dom_form_max_limit", 7], + ["browser.sessionstore.debug.no_auto_updates", true], + ], + }); + + await BrowserTestUtils.withNewTab({ gBrowser, url: URL }, async browser => { + await setPropertyOfFormField(browser, "#txt", "value", SHORT_VALUE); + await TabStateFlusher.flush(browser); + + let tab = gBrowser.getTabForBrowser(browser); + let state = JSON.parse(ss.getTabState(tab)); + is( + state.formdata.id.txt, + SHORT_VALUE, + "total length shorter than browser.sessionstore.dom_form_max_limit is ok." + ); + + await setPropertyOfFormField(browser, "#txt", "value", LONG_VALUE); + await TabStateFlusher.flush(browser); + + state = JSON.parse(ss.getTabState(tab)); + is( + state.formdata.id.txt, + SHORT_VALUE, + "total length shorter than browser.sessionstore.dom_form_max_limit isn't ok." + ); + }); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_form_max_limit_many_fields() { + await SpecialPowers.pushPrefEnv({ + set: [ + // "browser.sessionstore.dom_form_max_limit" limits the total length + // of values AND length id/xpath collected from a form. Here we have + // that SHORT_VALUE * 2 + 'text' + 'txt' is less than 15 and LONG_VALUE + // + SHORT_VALUE + 'text' + 'txt' is greater than 15. + ["browser.sessionstore.dom_form_max_limit", 15], + ["browser.sessionstore.debug.no_auto_updates", true], + ], + }); + + await BrowserTestUtils.withNewTab({ gBrowser, url: URL }, async browser => { + await SpecialPowers.spawn(browser, [], () => { + let element = content.document.createElement("input"); + element.id = "text"; + element.type = "text"; + content.document.body.appendChild(element); + }); + + await setPropertyOfFormField(browser, "#txt", "value", SHORT_VALUE); + await setPropertyOfFormField(browser, "#text", "value", SHORT_VALUE); + await TabStateFlusher.flush(browser); + + let tab = gBrowser.getTabForBrowser(browser); + let state = JSON.parse(ss.getTabState(tab)); + is( + state.formdata.id.txt, + SHORT_VALUE, + "total length shorter than browser.sessionstore.dom_form_max_limit is ok." + ); + + await setPropertyOfFormField(browser, "#txt", "value", LONG_VALUE); + await TabStateFlusher.flush(browser); + + state = JSON.parse(ss.getTabState(tab)); + is( + state.formdata.id.txt, + SHORT_VALUE, + "total length shorter than browser.sessionstore.dom_form_max_limit isn't ok." + ); + }); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/sessionstore/test/browser_formdata_password.js b/browser/components/sessionstore/test/browser_formdata_password.js new file mode 100644 index 0000000000..cb31bbd402 --- /dev/null +++ b/browser/components/sessionstore/test/browser_formdata_password.js @@ -0,0 +1,69 @@ +"use strict"; + +/** + * Ensures that <input>s that are/were type=password are not saved. + */ + +addCoopTask("file_formdata_password.html", test_hasBeenTypePassword, HTTPSROOT); + +addNonCoopTask( + "file_formdata_password.html", + test_hasBeenTypePassword, + HTTPROOT +); +addNonCoopTask( + "file_formdata_password.html", + test_hasBeenTypePassword, + HTTPSROOT +); + +async function test_hasBeenTypePassword(aURL) { + let tab = BrowserTestUtils.addTab(gBrowser, aURL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + await SpecialPowers.spawn(browser, [], async function fillFields() { + let doc = content.document; + + doc.getElementById("TextValue").setUserInput("abc"); + + doc.getElementById("TextValuePassword").setUserInput("def"); + doc.getElementById("TextValuePassword").type = "password"; + + doc.getElementById("TextPasswordValue").type = "password"; + doc.getElementById("TextPasswordValue").setUserInput("ghi"); + + doc.getElementById("PasswordValueText").setUserInput("jkl"); + doc.getElementById("PasswordValueText").type = "text"; + + doc.getElementById("PasswordTextValue").type = "text"; + doc.getElementById("PasswordTextValue").setUserInput("mno"); + + doc.getElementById("PasswordValue").setUserInput("pqr"); + }); + + // Remove the tab. + await promiseRemoveTabAndSessionState(tab); + + let [ + { + state: { formdata }, + }, + ] = ss.getClosedTabData(window); + let expected = [ + ["TextValue", "abc"], + ["TextValuePassword", undefined], + ["TextPasswordValue", undefined], + ["PasswordValueText", undefined], + ["PasswordTextValue", undefined], + ["PasswordValue", undefined], + ]; + + for (let [id, expectedValue] of expected) { + is( + formdata.id[id], + expectedValue, + `Value should be ${expectedValue} for ${id}` + ); + } +} diff --git a/browser/components/sessionstore/test/browser_formdata_sample.html b/browser/components/sessionstore/test/browser_formdata_sample.html new file mode 100644 index 0000000000..d469f5751e --- /dev/null +++ b/browser/components/sessionstore/test/browser_formdata_sample.html @@ -0,0 +1,20 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>browser_formdata_sample.html</title> + </head> + <body> + <input id="txt" /> + <iframe id="iframe"></iframe> + + <script type="text/javascript"> + let isOuter = window == window.top; + + if (isOuter) { + let iframe = document.getElementById("iframe"); + iframe.setAttribute("src", "https://example.com" + location.pathname); + } + </script> + </body> +</html> diff --git a/browser/components/sessionstore/test/browser_formdata_xpath.js b/browser/components/sessionstore/test/browser_formdata_xpath.js new file mode 100644 index 0000000000..37644d7c5b --- /dev/null +++ b/browser/components/sessionstore/test/browser_formdata_xpath.js @@ -0,0 +1,227 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = ROOT + "browser_formdata_xpath_sample.html"; + +/** + * Bug 346337 - Generic form data restoration tests. + */ +add_setup(function() { + // make sure we don't save form data at all (except for tab duplication) + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 2); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); + }); +}); + +const FILE1 = createFilePath("346337_test1.file"); +const FILE2 = createFilePath("346337_test2.file"); + +const FIELDS = { + "//input[@name='input']": Date.now().toString(16), + "//input[@name='spaced 1']": Math.random().toString(), + "//input[3]": "three", + "//input[@type='checkbox']": true, + "//input[@name='uncheck']": false, + "//input[@type='radio'][1]": false, + "//input[@type='radio'][2]": true, + "//input[@type='radio'][3]": false, + "//select": 2, + "//select[@multiple]": [1, 3], + "//textarea[1]": "", + "//textarea[2]": "Some text... " + Math.random(), + "//textarea[3]": "Some more text\n" + new Date(), + "//input[@type='file'][1]": [FILE1], + "//input[@type='file'][2]": [FILE1, FILE2], +}; + +add_task(async function test_form_data_restoration() { + // Load page with some input fields. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Fill in some values. + for (let xpath of Object.keys(FIELDS)) { + await setFormValue(browser, xpath); + } + + // Duplicate the tab. + let tab2 = gBrowser.duplicateTab(tab); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + // Check that all form values have been duplicated. + for (let xpath of Object.keys(FIELDS)) { + let expected = JSON.stringify(FIELDS[xpath]); + let actual = JSON.stringify(await getFormValue(browser2, xpath)); + is( + actual, + expected, + 'The value for "' + xpath + '" was correctly restored' + ); + } + + // Remove all tabs. + await promiseRemoveTabAndSessionState(tab2); + await promiseRemoveTabAndSessionState(tab); + + // Restore one of the tabs again. + tab = ss.undoCloseTab(window, 0); + browser = tab.linkedBrowser; + await promiseTabRestored(tab); + + // Check that none of the form values have been restored due to the privacy + // level settings. + for (let xpath of Object.keys(FIELDS)) { + let expected = FIELDS[xpath]; + if (expected) { + let actual = await getFormValue(browser, xpath, expected); + isnot( + actual, + expected, + 'The value for "' + xpath + '" was correctly discarded' + ); + } + } + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); + +function getPropertyOfXPath(browserContext, path, propName) { + return SpecialPowers.spawn( + browserContext, + [path, propName], + (pathChild, propNameChild) => { + let doc = content.document; + let xptype = doc.defaultView.XPathResult.FIRST_ORDERED_NODE_TYPE; + let node = doc.evaluate(pathChild, doc, null, xptype, null) + .singleNodeValue; + return node[propNameChild]; + } + ); +} + +function setPropertyOfXPath(browserContext, path, propName, newValue) { + return SpecialPowers.spawn( + browserContext, + [path, propName, newValue], + (pathChild, propNameChild, newValueChild) => { + let doc = content.document; + let xptype = doc.defaultView.XPathResult.FIRST_ORDERED_NODE_TYPE; + let node = doc.evaluate(pathChild, doc, null, xptype, null) + .singleNodeValue; + node[propNameChild] = newValueChild; + + let event = node.ownerDocument.createEvent("UIEvents"); + event.initUIEvent("input", true, true, node.ownerGlobal, 0); + node.dispatchEvent(event); + } + ); +} + +function execUsingXPath(browserContext, path, fnName, arg) { + return SpecialPowers.spawn( + browserContext, + [path, fnName, arg], + (pathChild, fnNameChild, argChild) => { + let doc = content.document; + let xptype = doc.defaultView.XPathResult.FIRST_ORDERED_NODE_TYPE; + let node = doc.evaluate(pathChild, doc, null, xptype, null) + .singleNodeValue; + + switch (fnNameChild) { + case "getMultipleSelected": + return Array.from(node.options, (opt, idx) => idx).filter( + idx => node.options[idx].selected + ); + case "setMultipleSelected": + Array.prototype.forEach.call( + node.options, + (opt, idx) => (opt.selected = argChild.indexOf(idx) > -1) + ); + break; + case "getFileNameArray": + return node.mozGetFileNameArray(); + case "setFileNameArray": + node.mozSetFileNameArray(argChild, argChild.length); + break; + } + + let event = node.ownerDocument.createEvent("UIEvents"); + event.initUIEvent("input", true, true, node.ownerGlobal, 0); + node.dispatchEvent(event); + return undefined; + } + ); +} + +function createFilePath(leaf) { + let file = Services.dirsvc.get("TmpD", Ci.nsIFile); + file.append(leaf); + return file.path; +} + +function isArrayOfNumbers(value) { + return Array.isArray(value) && value.every(n => typeof n === "number"); +} + +function isArrayOfStrings(value) { + return Array.isArray(value) && value.every(n => typeof n === "string"); +} + +function getFormValue(browser, xpath) { + let value = FIELDS[xpath]; + + if (typeof value == "string") { + return getPropertyOfXPath(browser, xpath, "value"); + } + + if (typeof value == "boolean") { + return getPropertyOfXPath(browser, xpath, "checked"); + } + + if (typeof value == "number") { + return getPropertyOfXPath(browser, xpath, "selectedIndex"); + } + + if (isArrayOfNumbers(value)) { + return execUsingXPath(browser, xpath, "getMultipleSelected"); + } + + if (isArrayOfStrings(value)) { + return execUsingXPath(browser, xpath, "getFileNameArray"); + } + + throw new Error("unknown input type"); +} + +function setFormValue(browser, xpath) { + let value = FIELDS[xpath]; + + if (typeof value == "string") { + return setPropertyOfXPath(browser, xpath, "value", value); + } + + if (typeof value == "boolean") { + return setPropertyOfXPath(browser, xpath, "checked", value); + } + + if (typeof value == "number") { + return setPropertyOfXPath(browser, xpath, "selectedIndex", value); + } + + if (isArrayOfNumbers(value)) { + return execUsingXPath(browser, xpath, "setMultipleSelected", value); + } + + if (isArrayOfStrings(value)) { + return execUsingXPath(browser, xpath, "setFileNameArray", value); + } + + throw new Error("unknown input type"); +} diff --git a/browser/components/sessionstore/test/browser_formdata_xpath_sample.html b/browser/components/sessionstore/test/browser_formdata_xpath_sample.html new file mode 100644 index 0000000000..682162d6a3 --- /dev/null +++ b/browser/components/sessionstore/test/browser_formdata_xpath_sample.html @@ -0,0 +1,37 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"> +<title>Test for bug 346337</title> + +<h3>Text Fields</h3> +<input type="text" name="input"> +<input type="text" name="spaced 1"> +<input> + +<h3>Checkboxes and Radio buttons</h3> +<input type="checkbox" name="check"> Check 1 +<input type="checkbox" name="uncheck" checked> Check 2 +<p> +<input type="radio" name="group" value="1"> Radio 1 +<input type="radio" name="group" value="some"> Radio 2 +<input type="radio" name="group" checked> Radio 3 + +<h3>Selects</h3> +<select name="any"> + <option value="1"> Select 1 + <option value="some"> Select 2 + <option>Select 3 +</select> +<select multiple="multiple"> + <option value=1> Multi-select 1 + <option value=2> Multi-select 2 + <option value=3> Multi-select 3 + <option value=4> Multi-select 4 +</select> + +<h3>Text Areas</h3> +<textarea name="testarea"></textarea> +<textarea name="sized one" rows="5" cols="25"></textarea> +<textarea></textarea> + +<h3>File Selector</h3> +<input type="file"> +<input type="file" multiple> diff --git a/browser/components/sessionstore/test/browser_frame_history.js b/browser/components/sessionstore/test/browser_frame_history.js new file mode 100644 index 0000000000..8ebfb931e2 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history.js @@ -0,0 +1,232 @@ +/* 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/. */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +/** + Ensure that frameset history works properly when restoring a tab, + provided that the frameset is static. + */ + +// Loading a toplevel frameset +add_task(async function() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + + let testURL = + getRootDirectory(gTestPath) + "browser_frame_history_index.html"; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + gBrowser.selectedTab = tab; + + info("Opening a page with three frames, 4 loads should take place"); + await waitForLoadsInBrowser(tab.linkedBrowser, 4); + + let browser_b = tab.linkedBrowser.contentDocument.getElementsByTagName( + "frame" + )[1]; + let document_b = browser_b.contentDocument; + let links = document_b.getElementsByTagName("a"); + + // We're going to click on the first link, so listen for another load event + info("Clicking on link 1, 1 load should take place"); + let promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[0], + browser_b.contentWindow + ); + await promise; + + info("Clicking on link 2, 1 load should take place"); + promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[1], + browser_b.contentWindow + ); + await promise; + + info("Close then un-close page, 4 loads should take place"); + await promiseRemoveTabAndSessionState(tab); + let newTab = ss.undoCloseTab(window, 0); + await waitForLoadsInBrowser(newTab.linkedBrowser, 4); + + info("Go back in time, 1 load should take place"); + gBrowser.goBack(); + await waitForLoadsInBrowser(newTab.linkedBrowser, 1); + + let expectedURLEnds = ["a.html", "b.html", "c1.html"]; + let frames = newTab.linkedBrowser.contentDocument.getElementsByTagName( + "frame" + ); + for (let i = 0; i < frames.length; i++) { + is( + frames[i].contentDocument.location.href, + getRootDirectory(gTestPath) + + "browser_frame_history_" + + expectedURLEnds[i], + "frame " + i + " has the right url" + ); + } + gBrowser.removeTab(newTab); +}); + +// Loading the frameset inside an iframe +add_task(async function() { + let testURL = + getRootDirectory(gTestPath) + "browser_frame_history_index2.html"; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + gBrowser.selectedTab = tab; + + info( + "iframe: Opening a page with an iframe containing three frames, 5 loads should take place" + ); + await waitForLoadsInBrowser(tab.linkedBrowser, 5); + + let browser_b = tab.linkedBrowser.contentDocument + .getElementById("iframe") + .contentDocument.getElementsByTagName("frame")[1]; + let document_b = browser_b.contentDocument; + let links = document_b.getElementsByTagName("a"); + + // We're going to click on the first link, so listen for another load event + info("iframe: Clicking on link 1, 1 load should take place"); + let promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[0], + browser_b.contentWindow + ); + await promise; + + info("iframe: Clicking on link 2, 1 load should take place"); + promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[1], + browser_b.contentWindow + ); + await promise; + + info("iframe: Close then un-close page, 5 loads should take place"); + await promiseRemoveTabAndSessionState(tab); + let newTab = ss.undoCloseTab(window, 0); + await waitForLoadsInBrowser(newTab.linkedBrowser, 5); + + info("iframe: Go back in time, 1 load should take place"); + gBrowser.goBack(); + await waitForLoadsInBrowser(newTab.linkedBrowser, 1); + + let expectedURLEnds = ["a.html", "b.html", "c1.html"]; + let frames = newTab.linkedBrowser.contentDocument + .getElementById("iframe") + .contentDocument.getElementsByTagName("frame"); + for (let i = 0; i < frames.length; i++) { + is( + frames[i].contentDocument.location.href, + getRootDirectory(gTestPath) + + "browser_frame_history_" + + expectedURLEnds[i], + "frame " + i + " has the right url" + ); + } + gBrowser.removeTab(newTab); +}); + +// Now, test that we don't record history if the iframe is added dynamically +add_task(async function() { + // Start with an empty history + let blankState = JSON.stringify({ + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + _closedTabs: [], + }, + ], + _closedWindows: [], + }); + await setBrowserState(blankState); + + let testURL = + getRootDirectory(gTestPath) + "browser_frame_history_index_blank.html"; + let tab = BrowserTestUtils.addTab(gBrowser, testURL); + gBrowser.selectedTab = tab; + await waitForLoadsInBrowser(tab.linkedBrowser, 1); + + info( + "dynamic: Opening a page with an iframe containing three frames, 4 dynamic loads should take place" + ); + let doc = tab.linkedBrowser.contentDocument; + let iframe = doc.createElement("iframe"); + iframe.id = "iframe"; + iframe.src = "browser_frame_history_index.html"; + doc.body.appendChild(iframe); + await waitForLoadsInBrowser(tab.linkedBrowser, 4); + + let browser_b = tab.linkedBrowser.contentDocument + .getElementById("iframe") + .contentDocument.getElementsByTagName("frame")[1]; + let document_b = browser_b.contentDocument; + let links = document_b.getElementsByTagName("a"); + + // We're going to click on the first link, so listen for another load event + info("dynamic: Clicking on link 1, 1 load should take place"); + let promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[0], + browser_b.contentWindow + ); + await promise; + + info("dynamic: Clicking on link 2, 1 load should take place"); + promise = waitForLoadsInBrowser(tab.linkedBrowser, 1); + EventUtils.sendMouseEvent( + { type: "click" }, + links[1], + browser_b.contentWindow + ); + await promise; + + info("Check in the state that we have not stored this history"); + let state = ss.getBrowserState(); + info(JSON.stringify(JSON.parse(state), null, "\t")); + is( + state.indexOf("c1.html"), + -1, + "History entry was not stored in the session state" + ); + gBrowser.removeTab(tab); +}); + +// helper functions +function waitForLoadsInBrowser(aBrowser, aLoadCount) { + return new Promise(resolve => { + let loadCount = 0; + aBrowser.addEventListener( + "load", + function listener(aEvent) { + if (++loadCount < aLoadCount) { + info( + "Got " + loadCount + " loads, waiting until we have " + aLoadCount + ); + return; + } + + aBrowser.removeEventListener("load", listener, true); + resolve(); + }, + true + ); + }); +} + +function timeout(delay, task) { + return new Promise((resolve, reject) => { + setTimeout(() => resolve(true), delay); + task.then(() => resolve(false), reject); + }); +} diff --git a/browser/components/sessionstore/test/browser_frame_history_a.html b/browser/components/sessionstore/test/browser_frame_history_a.html new file mode 100644 index 0000000000..8e7b35d7a1 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_a.html @@ -0,0 +1,5 @@ +<html> + <body> + I'm A! + </body> +</html> diff --git a/browser/components/sessionstore/test/browser_frame_history_b.html b/browser/components/sessionstore/test/browser_frame_history_b.html new file mode 100644 index 0000000000..38b43da211 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_b.html @@ -0,0 +1,10 @@ +<html> + <body> + I'm B!<br/> + <a target="c" href="browser_frame_history_c1.html">click me first</a><br/> + <a target="c" href="browser_frame_history_c2.html">then click me</a><br/> + Close this tab.<br/> + Restore this tab.<br/> + Click back.<br/> + </body> +</html> diff --git a/browser/components/sessionstore/test/browser_frame_history_c.html b/browser/components/sessionstore/test/browser_frame_history_c.html new file mode 100644 index 0000000000..0efd7d9026 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_c.html @@ -0,0 +1,5 @@ +<html> + <body> + I'm C! + </body> +</html> diff --git a/browser/components/sessionstore/test/browser_frame_history_c1.html b/browser/components/sessionstore/test/browser_frame_history_c1.html new file mode 100644 index 0000000000..b55c1d45a9 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_c1.html @@ -0,0 +1,5 @@ +<html> + <body> + I'm C1! + </body> +</html> diff --git a/browser/components/sessionstore/test/browser_frame_history_c2.html b/browser/components/sessionstore/test/browser_frame_history_c2.html new file mode 100644 index 0000000000..aec504141b --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_c2.html @@ -0,0 +1,5 @@ +<html> + <body> + I'm C2! + </body> +</html> diff --git a/browser/components/sessionstore/test/browser_frame_history_index.html b/browser/components/sessionstore/test/browser_frame_history_index.html new file mode 100644 index 0000000000..04a44555ab --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_index.html @@ -0,0 +1,9 @@ +<html> + <frameset cols="20%,80%"> + <frameset rows="30%,70%"> + <frame src="browser_frame_history_a.html"/> + <frame src="browser_frame_history_b.html"/> + </frameset> + <frame src="browser_frame_history_c.html" name="c"/> + </frameset> +</html> diff --git a/browser/components/sessionstore/test/browser_frame_history_index2.html b/browser/components/sessionstore/test/browser_frame_history_index2.html new file mode 100644 index 0000000000..d465abef62 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_index2.html @@ -0,0 +1,3 @@ +<html> + <iframe src="browser_frame_history_index.html" id="iframe" /> +</html> diff --git a/browser/components/sessionstore/test/browser_frame_history_index_blank.html b/browser/components/sessionstore/test/browser_frame_history_index_blank.html new file mode 100644 index 0000000000..4ddd1a7cf7 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frame_history_index_blank.html @@ -0,0 +1,4 @@ +<html> + <body> + </body> +</html> diff --git a/browser/components/sessionstore/test/browser_frametree.js b/browser/components/sessionstore/test/browser_frametree.js new file mode 100644 index 0000000000..7a9f10838b --- /dev/null +++ b/browser/components/sessionstore/test/browser_frametree.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = HTTPROOT + "browser_frametree_sample.html"; +const URL_FRAMESET = HTTPROOT + "browser_frametree_sample_frameset.html"; +const URL_IFRAMES = HTTPROOT + "browser_frametree_sample_iframes.html"; + +/** + * Check that we correctly enumerate non-dynamic child frames. + */ +add_task(async function test_frametree() { + // Add an empty tab for a start. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // The page is a single frame with no children. + is(await countNonDynamicFrames(browser), 0, "no child frames"); + + // Navigate to a frameset. + BrowserTestUtils.loadURI(browser, URL_FRAMESET); + await promiseBrowserLoaded(browser); + + // The frameset has two frames. + is(await countNonDynamicFrames(browser), 2, "two non-dynamic child frames"); + + // Go back in history. + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + browser, + "pageshow", + true + ); + browser.goBack(); + await pageShowPromise; + + // We're at page one again. + is(await countNonDynamicFrames(browser), 0, "no child frames"); + + // Append a dynamic frame. + await SpecialPowers.spawn(browser, [URL], async ([url]) => { + let frame = content.document.createElement("iframe"); + frame.setAttribute("src", url); + content.document.body.appendChild(frame); + await ContentTaskUtils.waitForEvent(frame, "load"); + }); + + // The dynamic frame should be ignored. + is( + await countNonDynamicFrames(browser), + 0, + "we still have a single root frame" + ); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); + +/** + * Check that we correctly enumerate non-dynamic child frames. + */ +add_task(async function test_frametree_dynamic() { + // Add an empty tab for a start. + let tab = BrowserTestUtils.addTab(gBrowser, URL_IFRAMES); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // The page has two iframes. + is(await countNonDynamicFrames(browser), 2, "two non-dynamic child frames"); + is(await enumerateIndexes(browser), "0,1", "correct indexes 0 and 1"); + + // Insert a dynamic frame. + await SpecialPowers.spawn(browser, [URL], async ([url]) => { + let frame = content.document.createElement("iframe"); + frame.setAttribute("src", url); + content.document.body.insertBefore( + frame, + content.document.getElementsByTagName("iframe")[1] + ); + await ContentTaskUtils.waitForEvent(frame, "load"); + }); + + // The page still has two iframes. + is(await countNonDynamicFrames(browser), 2, "two non-dynamic child frames"); + is(await enumerateIndexes(browser), "0,1", "correct indexes 0 and 1"); + + // Append a dynamic frame. + await SpecialPowers.spawn(browser, [URL], async ([url]) => { + let frame = content.document.createElement("iframe"); + frame.setAttribute("src", url); + content.document.body.appendChild(frame); + await ContentTaskUtils.waitForEvent(frame, "load"); + }); + + // The page still has two iframes. + is(await countNonDynamicFrames(browser), 2, "two non-dynamic child frames"); + is(await enumerateIndexes(browser), "0,1", "correct indexes 0 and 1"); + + // Remopve a non-dynamic iframe. + await SpecialPowers.spawn(browser, [URL], async ([url]) => { + // Remove the first iframe, which should be a non-dynamic iframe. + content.document.body.removeChild( + content.document.getElementsByTagName("iframe")[0] + ); + }); + + is(await countNonDynamicFrames(browser), 1, "one non-dynamic child frame"); + is(await enumerateIndexes(browser), "1", "correct index 1"); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); + +async function countNonDynamicFrames(browser) { + return SpecialPowers.spawn(browser, [], async () => { + let count = 0; + content.SessionStoreUtils.forEachNonDynamicChildFrame( + content, + () => count++ + ); + return count; + }); +} + +async function enumerateIndexes(browser) { + return SpecialPowers.spawn(browser, [], async () => { + let indexes = []; + content.SessionStoreUtils.forEachNonDynamicChildFrame(content, (frame, i) => + indexes.push(i) + ); + return indexes.join(","); + }); +} diff --git a/browser/components/sessionstore/test/browser_frametree_sample.html b/browser/components/sessionstore/test/browser_frametree_sample.html new file mode 100644 index 0000000000..dda129448c --- /dev/null +++ b/browser/components/sessionstore/test/browser_frametree_sample.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>browser_frametree_sample.html</title> + </head> + <body style='width: 100000px; height: 100000px;'>top</body> +</html> diff --git a/browser/components/sessionstore/test/browser_frametree_sample_frameset.html b/browser/components/sessionstore/test/browser_frametree_sample_frameset.html new file mode 100644 index 0000000000..e1cd087357 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frametree_sample_frameset.html @@ -0,0 +1,11 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Frameset//EN"> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>browser_frametree_sample_frameset.html</title> + </head> + <frameset id="frames" rows="50%, 50%"> + <frame src="browser_frametree_sample.html"> + <frame src="browser_frametree_sample.html"> + </frameset> +</html> diff --git a/browser/components/sessionstore/test/browser_frametree_sample_iframes.html b/browser/components/sessionstore/test/browser_frametree_sample_iframes.html new file mode 100644 index 0000000000..aaffab8af4 --- /dev/null +++ b/browser/components/sessionstore/test/browser_frametree_sample_iframes.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Frameset//EN"> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>browser_frametree_sample_iframes.html</title> + </head> + <iframe src="browser_frametree_sample.html"></iframe> + <iframe src="browser_frametree_sample.html"></iframe> +</html> diff --git a/browser/components/sessionstore/test/browser_global_store.js b/browser/components/sessionstore/test/browser_global_store.js new file mode 100644 index 0000000000..442fc12c4c --- /dev/null +++ b/browser/components/sessionstore/test/browser_global_store.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests the API for saving global session data. +add_task(async function() { + const key1 = "Unique name 1: " + Date.now(); + const key2 = "Unique name 2: " + Date.now(); + const value1 = "Unique value 1: " + Math.random(); + const value2 = "Unique value 2: " + Math.random(); + + let global = {}; + global[key1] = value1; + + const testState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + global, + }; + + function testRestoredState() { + is( + ss.getCustomGlobalValue(key1), + value1, + "restored state has global value" + ); + } + + function testGlobalStore() { + is(ss.getCustomGlobalValue(key2), "", "global value initially not set"); + + ss.setCustomGlobalValue(key2, value1); + is(ss.getCustomGlobalValue(key2), value1, "retreived value matches stored"); + + ss.setCustomGlobalValue(key2, value2); + is( + ss.getCustomGlobalValue(key2), + value2, + "previously stored value was overwritten" + ); + + ss.deleteCustomGlobalValue(key2); + is(ss.getCustomGlobalValue(key2), "", "global value was deleted"); + } + + await promiseBrowserState(testState); + testRestoredState(); + testGlobalStore(); +}); diff --git a/browser/components/sessionstore/test/browser_history_persist.js b/browser/components/sessionstore/test/browser_history_persist.js new file mode 100644 index 0000000000..36dfb457c5 --- /dev/null +++ b/browser/components/sessionstore/test/browser_history_persist.js @@ -0,0 +1,163 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Ensure that history entries that should not be persisted are restored in the + * same state. + */ +add_task(async function check_history_not_persisted() { + // Create an about:blank tab + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Retrieve the tab state. + await TabStateFlusher.flush(browser); + let state = JSON.parse(ss.getTabState(tab)); + ok(!state.entries[0].persist, "Should have collected the persistence state"); + BrowserTestUtils.removeTab(tab); + browser = null; + + // Open a new tab to restore into. + tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + browser = tab.linkedBrowser; + await promiseTabState(tab, state); + + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function() { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + } + + // Load a new URL into the tab, it should replace the about:blank history entry + BrowserTestUtils.loadURI(browser, "about:robots"); + await promiseBrowserLoaded(browser, false, "about:robots"); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function() { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:robots", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:robots", + "Should be the right URL" + ); + } + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); + +/** + * Check that entries default to being persisted when the attribute doesn't + * exist + */ +add_task(async function check_history_default_persisted() { + // Create an about:blank tab + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Retrieve the tab state. + await TabStateFlusher.flush(browser); + let state = JSON.parse(ss.getTabState(tab)); + delete state.entries[0].persist; + BrowserTestUtils.removeTab(tab); + browser = null; + + // Open a new tab to restore into. + tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + browser = tab.linkedBrowser; + await promiseTabState(tab, state); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function() { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + } + + // Load a new URL into the tab, it should replace the about:blank history entry + BrowserTestUtils.loadURI(browser, "about:robots"); + await promiseBrowserLoaded(browser, false, "about:robots"); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function() { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; + + is(sessionHistory.count, 2, "Should be two history entries"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + is( + sessionHistory.getEntryAtIndex(1).URI.spec, + "about:robots", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 2, "Should be two history entries"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + is( + sessionHistory.getEntryAtIndex(1).URI.spec, + "about:robots", + "Should be the right URL" + ); + } + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_ignore_updates_crashed_tabs.js b/browser/components/sessionstore/test/browser_ignore_updates_crashed_tabs.js new file mode 100644 index 0000000000..5a9acc6534 --- /dev/null +++ b/browser/components/sessionstore/test/browser_ignore_updates_crashed_tabs.js @@ -0,0 +1,108 @@ +// This test checks that browsers are removed from the SessionStore's +// crashed browser set at a correct time, so that it can stop ignoring update +// events coming from those browsers. + +/** + * Open a tab, crash it, navigate it to a remote uri, and check that it + * is removed from a crashed set. + */ +add_task(async function test_update_crashed_tab_after_navigate_to_remote() { + let tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + let browser = tab.linkedBrowser; + gBrowser.selectedTab = tab; + await promiseBrowserLoaded(browser); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + + await BrowserTestUtils.crashFrame(browser); + ok( + SessionStore.isBrowserInCrashedSet(browser), + "browser is in the crashed set" + ); + + BrowserTestUtils.loadURI(browser, "https://example.org/"); + await BrowserTestUtils.browserLoaded(browser, false, "https://example.org/"); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + ok( + !tab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + gBrowser.removeTab(tab); +}); + +/** + * Open a tab, crash it, navigate it to a non-remote uri, and check that it + * is removed from a crashed set. + */ +add_task(async function test_update_crashed_tab_after_navigate_to_non_remote() { + let tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + let browser = tab.linkedBrowser; + gBrowser.selectedTab = tab; + await promiseBrowserLoaded(browser); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + + await BrowserTestUtils.crashFrame(browser); + ok( + SessionStore.isBrowserInCrashedSet(browser), + "browser is in the crashed set" + ); + + BrowserTestUtils.loadURI(browser, "about:mozilla"); + await BrowserTestUtils.browserLoaded(browser, false, "about:mozilla"); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + ok( + !gBrowser.selectedTab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + gBrowser.removeTab(tab); +}); + +/** + * Open a tab, crash it, restore it from history, and check that it + * is removed from a crashed set. + */ +add_task(async function test_update_crashed_tab_after_session_restore() { + let tab = BrowserTestUtils.addTab(gBrowser, "https://example.com/"); + let browser = tab.linkedBrowser; + gBrowser.selectedTab = tab; + await promiseBrowserLoaded(browser); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + + await BrowserTestUtils.crashFrame(browser); + ok( + SessionStore.isBrowserInCrashedSet(browser), + "browser is in the crashed set" + ); + + let tabRestoredPromise = promiseTabRestored(tab); + // Click restoreTab button + await SpecialPowers.spawn(browser, [], () => { + let button = content.document.getElementById("restoreTab"); + button.click(); + }); + await tabRestoredPromise; + ok( + !tab.hasAttribute("crashed"), + "Tab shouldn't be marked as crashed anymore." + ); + ok( + !SessionStore.isBrowserInCrashedSet(browser), + "browser is not in the crashed set" + ); + + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_label_and_icon.js b/browser/components/sessionstore/test/browser_label_and_icon.js new file mode 100644 index 0000000000..9b254c5e77 --- /dev/null +++ b/browser/components/sessionstore/test/browser_label_and_icon.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Ensure that a pending tab has label and icon correctly set. + */ +add_task(async function test_label_and_icon() { + // Make sure that tabs are restored on demand as otherwise the tab will start + // loading immediately and we can't check its icon and label. + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.restore_on_demand", true]], + }); + + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:robots"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + // Because there is debounce logic in ContentLinkHandler.jsm to reduce the + // favicon loads, we have to wait some time before checking that icon was + // stored properly. + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon(tab) != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + + // Retrieve the tab state. + await TabStateFlusher.flush(browser); + let state = ss.getTabState(tab); + BrowserTestUtils.removeTab(tab); + browser = null; + + // Open a new tab to restore into. + tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + ss.setTabState(tab, state); + await promiseTabRestoring(tab); + + // Check that label and icon are set for the restoring tab. + is( + gBrowser.getIcon(tab), + "chrome://browser/content/robot.ico", + "icon is set" + ); + is(tab.label, "Gort! Klaatu barada nikto!", "label is set"); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_merge_closed_tabs.js b/browser/components/sessionstore/test/browser_merge_closed_tabs.js new file mode 100644 index 0000000000..cf88ed66f1 --- /dev/null +++ b/browser/components/sessionstore/test/browser_merge_closed_tabs.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures that closed tabs are merged when restoring + * a window state without overwriting tabs. + */ +add_task(async function() { + const initialState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + _closedTabs: [ + { + state: { + entries: [ + { ID: 1000, url: "about:blank", triggeringPrincipal_base64 }, + ], + }, + }, + { + state: { + entries: [ + { ID: 1001, url: "about:blank", triggeringPrincipal_base64 }, + ], + }, + }, + ], + }, + ], + }; + + const restoreState = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:robots", triggeringPrincipal_base64 }] }, + ], + _closedTabs: [ + { + state: { + entries: [ + { ID: 1002, url: "about:robots", triggeringPrincipal_base64 }, + ], + }, + }, + { + state: { + entries: [ + { ID: 1003, url: "about:robots", triggeringPrincipal_base64 }, + ], + }, + }, + { + state: { + entries: [ + { ID: 1004, url: "about:robots", triggeringPrincipal_base64 }, + ], + }, + }, + ], + }, + ], + }; + + const maxTabsUndo = 4; + Services.prefs.setIntPref("browser.sessionstore.max_tabs_undo", maxTabsUndo); + + // Open a new window and restore it to an initial state. + let win = await promiseNewWindowLoaded({ private: false }); + await setWindowState(win, initialState, true); + is( + SessionStore.getClosedTabCount(win), + 2, + "2 closed tabs after restoring initial state" + ); + + // Restore the new state but do not overwrite existing tabs (this should + // cause the closed tabs to be merged). + await setWindowState(win, restoreState); + + // Verify the windows closed tab data is correct. + let iClosed = initialState.windows[0]._closedTabs; + let rClosed = restoreState.windows[0]._closedTabs; + let cData = SessionStore.getClosedTabData(win); + + is( + cData.length, + Math.min(iClosed.length + rClosed.length, maxTabsUndo), + "Number of closed tabs is correct" + ); + + // When the closed tabs are merged the restored tabs are considered to be + // closed more recently. + for (let i = 0; i < cData.length; i++) { + if (i < rClosed.length) { + is( + cData[i].state.entries[0].ID, + rClosed[i].state.entries[0].ID, + "Closed tab entry matches" + ); + } else { + is( + cData[i].state.entries[0].ID, + iClosed[i - rClosed.length].state.entries[0].ID, + "Closed tab entry matches" + ); + } + } + + // Clean up. + Services.prefs.clearUserPref("browser.sessionstore.max_tabs_undo"); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_movePendingTabToNewWindow.js b/browser/components/sessionstore/test/browser_movePendingTabToNewWindow.js new file mode 100644 index 0000000000..3a013132be --- /dev/null +++ b/browser/components/sessionstore/test/browser_movePendingTabToNewWindow.js @@ -0,0 +1,124 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This tests the behaviour of moving pending tabs to a new window. These + * pending tabs have yet to be restored and should be restored upon opening + * in the new window. This test covers moving a single pending tab at once + * as well as multiple tabs at the same time (using tab multiselection). + */ +add_task(async function test_movePendingTabToNewWindow() { + const TEST_URIS = [ + "http://www.example.com/1", + "http://www.example.com/2", + "http://www.example.com/3", + "http://www.example.com/4", + ]; + + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.restore_on_demand", true]], + }); + + let state = { + windows: [ + { + tabs: [ + { entries: [{ url: TEST_URIS[0], triggeringPrincipal_base64 }] }, + { entries: [{ url: TEST_URIS[1], triggeringPrincipal_base64 }] }, + { entries: [{ url: TEST_URIS[2], triggeringPrincipal_base64 }] }, + { entries: [{ url: TEST_URIS[3], triggeringPrincipal_base64 }] }, + ], + selected: 4, + }, + ], + }; + + await promiseBrowserState(state); + + is( + gBrowser.visibleTabs.length, + 4, + "Three tabs are visible to start the test" + ); + + let tabToSelect = gBrowser.visibleTabs[1]; + ok(tabToSelect.hasAttribute("pending"), "Tab should be pending"); + + gBrowser.addRangeToMultiSelectedTabs(gBrowser.selectedTab, tabToSelect); + ok(!gBrowser.visibleTabs[0].multiselected, "First tab not multiselected"); + ok(gBrowser.visibleTabs[1].multiselected, "Second tab multiselected"); + ok(gBrowser.visibleTabs[2].multiselected, "Third tab multiselected"); + ok(gBrowser.visibleTabs[3].multiselected, "Fourth tab multiselected"); + + let promiseNewWindow = BrowserTestUtils.waitForNewWindow(); + gBrowser.replaceTabsWithWindow(tabToSelect); + + info("Waiting for new window"); + let newWindow = await promiseNewWindow; + isnot(newWindow, gBrowser.ownerGlobal, "Tab moved to new window"); + + let newWindowTabs = newWindow.gBrowser.visibleTabs; + await TestUtils.waitForCondition(() => { + return ( + newWindowTabs.length == 3 && + newWindowTabs[0].linkedBrowser.currentURI.spec == TEST_URIS[1] && + newWindowTabs[1].linkedBrowser.currentURI.spec == TEST_URIS[2] && + newWindowTabs[2].linkedBrowser.currentURI.spec == TEST_URIS[3] + ); + }, "Wait for all three tabs to move to new window and load"); + + is(newWindowTabs.length, 3, "Three tabs should be in new window"); + is( + newWindowTabs[0].linkedBrowser.currentURI.spec, + TEST_URIS[1], + "Second tab moved" + ); + is( + newWindowTabs[1].linkedBrowser.currentURI.spec, + TEST_URIS[2], + "Third tab moved" + ); + is( + newWindowTabs[2].linkedBrowser.currentURI.spec, + TEST_URIS[3], + "Fourth tab moved" + ); + + ok( + newWindowTabs[0].hasAttribute("pending"), + "First tab in new window should still be pending" + ); + ok( + newWindowTabs[1].hasAttribute("pending"), + "Second tab in new window should still be pending" + ); + newWindow.gBrowser.clearMultiSelectedTabs(); + ok( + newWindowTabs.every(t => !t.multiselected), + "No multiselection should be present" + ); + + promiseNewWindow = BrowserTestUtils.waitForNewWindow(); + newWindow.gBrowser.replaceTabsWithWindow(newWindowTabs[0]); + + info("Waiting for second new window"); + let secondNewWindow = await promiseNewWindow; + await TestUtils.waitForCondition( + () => + secondNewWindow.gBrowser.selectedBrowser.currentURI.spec == TEST_URIS[1], + "Wait until the URI is updated" + ); + is( + secondNewWindow.gBrowser.visibleTabs.length, + 1, + "Only one tab in second new window" + ); + is( + secondNewWindow.gBrowser.selectedBrowser.currentURI.spec, + TEST_URIS[1], + "First tab moved" + ); + + await BrowserTestUtils.closeWindow(secondNewWindow); + await BrowserTestUtils.closeWindow(newWindow); +}); diff --git a/browser/components/sessionstore/test/browser_multiple_navigateAndRestore.js b/browser/components/sessionstore/test/browser_multiple_navigateAndRestore.js new file mode 100644 index 0000000000..f267ba34a7 --- /dev/null +++ b/browser/components/sessionstore/test/browser_multiple_navigateAndRestore.js @@ -0,0 +1,45 @@ +"use strict"; + +const PAGE_1 = + "data:text/html,<html><body>A%20regular,%20everyday,%20normal%20page."; +const PAGE_2 = + "data:text/html,<html><body>Another%20regular,%20everyday,%20normal%20page."; + +add_task(async function() { + // Load an empty, non-remote tab at about:blank... + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + forceNotRemote: true, + }); + gBrowser.selectedTab = tab; + let browser = gBrowser.selectedBrowser; + ok(!browser.isRemoteBrowser, "Ensure browser is not remote"); + // Load a remote page, and then another remote page immediately + // after. + BrowserTestUtils.loadURI(browser, PAGE_1); + browser.stop(); + BrowserTestUtils.loadURI(browser, PAGE_2); + await BrowserTestUtils.browserLoaded(browser, false, PAGE_2); + + ok(browser.isRemoteBrowser, "Should have switched remoteness"); + await TabStateFlusher.flush(browser); + let state = JSON.parse(ss.getTabState(tab)); + let entries = state.entries; + is(entries.length, 1, "There should only be one entry"); + is(entries[0].url, PAGE_2, "Should have PAGE_2 as the sole history entry"); + is( + browser.currentURI.spec, + PAGE_2, + "Should have PAGE_2 as the browser currentURI" + ); + + await SpecialPowers.spawn(browser, [PAGE_2], async function(expectedURL) { + docShell.QueryInterface(Ci.nsIWebNavigation); + Assert.equal( + docShell.currentURI.spec, + expectedURL, + "Content should have PAGE_2 as the browser currentURI" + ); + }); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_multiple_select_after_load.js b/browser/components/sessionstore/test/browser_multiple_select_after_load.js new file mode 100644 index 0000000000..1d78f59487 --- /dev/null +++ b/browser/components/sessionstore/test/browser_multiple_select_after_load.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const URL = `data:text/html;charset=utf-8, +<select id="select"> + <option value="1"> 1 + <option value="2"> 2 + <option value="3"> 3 +</select>`; + +const VALUES = ["1", "3"]; + +// Tests that a document that changes a <select> element's "multiple" attribute +// *after* the load event (eg. perhaps in response to some user action) doesn't +// crash the browser when being restored. +add_task(async function() { + // Create new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + await promiseBrowserLoaded(tab.linkedBrowser); + + // Change the "multiple" attribute of the <select> element and select some + // options. + await setPropertyOfFormField(tab.linkedBrowser, "select", "multiple", true); + + for (let v of VALUES) { + await setPropertyOfFormField( + tab.linkedBrowser, + `option[value="${v}"]`, + "selected", + true + ); + } + + // Remove the tab. + await promiseRemoveTabAndSessionState(tab); + + // Verify state of the closed tab. + let tabData = ss.getClosedTabData(window); + Assert.deepEqual( + tabData[0].state.formdata.id.select, + VALUES, + "Collected correct formdata" + ); + + // Restore the close tab. + tab = ss.undoCloseTab(window, 0); + await promiseTabRestored(tab); + ok(true, "Didn't crash!"); + + // Cleanup. + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_newtab_userTypedValue.js b/browser/components/sessionstore/test/browser_newtab_userTypedValue.js new file mode 100644 index 0000000000..7f2c1e4d27 --- /dev/null +++ b/browser/components/sessionstore/test/browser_newtab_userTypedValue.js @@ -0,0 +1,92 @@ +"use strict"; + +requestLongerTimeout(4); + +/** + * Test that when restoring an 'initial page' with session restore, it + * produces an empty URL bar, rather than leaving its URL explicitly + * there as a 'user typed value'. + */ +add_task(async function() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:logo"); + let tabOpenedAndSwitchedTo = BrowserTestUtils.switchTab( + win.gBrowser, + () => {} + ); + + // This opens about:newtab: + win.BrowserOpenTab(); + let tab = await tabOpenedAndSwitchedTo; + is(win.gURLBar.value, "", "URL bar should be empty"); + is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); + let state = JSON.parse(SessionStore.getTabState(tab)); + ok( + !state.userTypedValue, + "userTypedValue should be undefined on the tab's state" + ); + tab = null; + + await BrowserTestUtils.closeWindow(win); + + ok(SessionStore.getClosedWindowCount(), "Should have a closed window"); + + await forceSaveState(); + + win = SessionStore.undoCloseWindow(0); + await TestUtils.topicObserved( + "sessionstore-single-window-restored", + subject => subject == win + ); + // Don't wait for load here because it's about:newtab and we may have swapped in + // a preloaded browser. + await TabStateFlusher.flush(win.gBrowser.selectedBrowser); + + is(win.gURLBar.value, "", "URL bar should be empty"); + tab = win.gBrowser.selectedTab; + is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); + state = JSON.parse(SessionStore.getTabState(tab)); + ok( + !state.userTypedValue, + "userTypedValue should be undefined on the tab's state" + ); + + BrowserTestUtils.removeTab(tab); + + for (let url of gInitialPages) { + if (url == BROWSER_NEW_TAB_URL) { + continue; // We tested about:newtab using BrowserOpenTab() above. + } + info("Testing " + url + " - " + new Date()); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + await BrowserTestUtils.closeWindow(win); + + ok(SessionStore.getClosedWindowCount(), "Should have a closed window"); + + await forceSaveState(); + + win = SessionStore.undoCloseWindow(0); + await TestUtils.topicObserved( + "sessionstore-single-window-restored", + subject => subject == win + ); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + await TabStateFlusher.flush(win.gBrowser.selectedBrowser); + + is(win.gURLBar.value, "", "URL bar should be empty"); + tab = win.gBrowser.selectedTab; + is(tab.linkedBrowser.userTypedValue, null, "userTypedValue should be null"); + state = JSON.parse(SessionStore.getTabState(tab)); + ok( + !state.userTypedValue, + "userTypedValue should be undefined on the tab's state" + ); + + info("Removing tab - " + new Date()); + BrowserTestUtils.removeTab(tab); + info("Finished removing tab - " + new Date()); + } + info("Removing window - " + new Date()); + await BrowserTestUtils.closeWindow(win); + info("Finished removing window - " + new Date()); +}); diff --git a/browser/components/sessionstore/test/browser_not_collect_when_idle.js b/browser/components/sessionstore/test/browser_not_collect_when_idle.js new file mode 100644 index 0000000000..23b37d9ebb --- /dev/null +++ b/browser/components/sessionstore/test/browser_not_collect_when_idle.js @@ -0,0 +1,118 @@ +/** Test for Bug 1305950 **/ + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +// The mock idle service. +var idleService = { + _observers: new Set(), + _activity: { + addCalls: [], + removeCalls: [], + observerFires: [], + }, + + _reset() { + this._observers.clear(); + this._activity.addCalls = []; + this._activity.removeCalls = []; + this._activity.observerFires = []; + }, + + _fireObservers(state) { + for (let observer of this._observers.values()) { + observer.observe(observer, state, null); + this._activity.observerFires.push(state); + } + }, + + QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]), + idleTime: 19999, + + addIdleObserver(observer, time) { + this._observers.add(observer); + this._activity.addCalls.push(time); + }, + + removeIdleObserver(observer, time) { + this._observers.delete(observer); + this._activity.removeCalls.push(time); + }, +}; + +add_task(async function testIntervalChanges() { + const PREF_SS_INTERVAL = 2000; + + // We speed up the interval between session saves to ensure that the test + // runs quickly. + Services.prefs.setIntPref("browser.sessionstore.interval", PREF_SS_INTERVAL); + + // Increase `idleDelay` to 1 day to update the pre-registered idle observer + // in "real" idle service to avoid possible interference, especially for the + // CI server environment. + Services.prefs.setIntPref("browser.sessionstore.idleDelay", 86400000); + + // Mock an idle service. + let fakeIdleService = MockRegistrar.register( + "@mozilla.org/widget/useridleservice;1", + idleService + ); + idleService._reset(); + + registerCleanupFunction(function() { + Services.prefs.clearUserPref("browser.sessionstore.interval"); + MockRegistrar.unregister(fakeIdleService); + }); + + // Hook idle/active observer to mock idle service by changing pref `idleDelay` + // to a whatever value, which will not be used. + Services.prefs.setIntPref("browser.sessionstore.idleDelay", 5000000); + + // Wait a `sessionstore-state-write-complete` event from any previous + // scheduled state write. This is needed since the `_lastSaveTime` in + // runDelayed() should be set at least once, or the `_isIdle` flag will not + // become effective. + info("Waiting for sessionstore-state-write-complete notification"); + await TestUtils.topicObserved("sessionstore-state-write-complete"); + + info( + "Got the sessionstore-state-write-complete notification, now testing idle mode" + ); + + // Enter the "idle mode" (raise the `_isIdle` flag) by firing idle + // observer of mock idle service. + idleService._fireObservers("idle"); + + // Cancel any possible state save, which is not related with this test to + // avoid interference. + SessionSaver.cancel(); + + let p1 = promiseSaveState(); + + // Schedule a state write, which is expeced to be postponed after about + // `browser.sessionstore.interval.idle` ms, since the idle flag was just set. + SessionSaver.runDelayed(0); + + // We expect `p1` hits the timeout. + await Assert.rejects( + p1, + /Save state timeout/, + "[Test 1A] No state write during idle." + ); + + // Test again for better reliability. Same, we expect following promise hits + // the timeout. + await Assert.rejects( + promiseSaveState(), + /Save state timeout/, + "[Test 1B] Again: No state write during idle." + ); + + // Back to the active mode. + info("Start to test active mode..."); + idleService._fireObservers("active"); + + info("[Test 2] Waiting for sessionstore-state-write-complete during active"); + await TestUtils.topicObserved("sessionstore-state-write-complete"); +}); diff --git a/browser/components/sessionstore/test/browser_old_favicon.js b/browser/components/sessionstore/test/browser_old_favicon.js new file mode 100644 index 0000000000..fc416e81f6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_old_favicon.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Ensure that we can restore old style favicon and principals. + */ +add_task(async function test_label_and_icon() { + // Make sure that tabs are restored on demand as otherwise the tab will start + // loading immediately and override the icon. + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.restore_on_demand", true]], + }); + + // Create a new tab. + let tab = BrowserTestUtils.addTab( + gBrowser, + "http://www.example.com/browser/browser/components/sessionstore/test/empty.html" + ); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + let contentPrincipal = browser.contentPrincipal; + let serializedPrincipal = E10SUtils.serializePrincipal(contentPrincipal); + + // Retrieve the tab state. + await TabStateFlusher.flush(browser); + let state = JSON.parse(ss.getTabState(tab)); + state.image = "http://www.example.com/favicon.ico"; + state.iconLoadingPrincipal = serializedPrincipal; + + BrowserTestUtils.removeTab(tab); + + // Open a new tab to restore into. + tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + ss.setTabState(tab, state); + await promiseTabRestoring(tab); + + // Check that label and icon are set for the restoring tab. + is( + gBrowser.getIcon(tab), + "http://www.example.com/favicon.ico", + "icon is set" + ); + is( + tab.getAttribute("image"), + "http://www.example.com/favicon.ico", + "tab image is set" + ); + is( + tab.getAttribute("iconloadingprincipal"), + serializedPrincipal, + "tab image loading principal is set" + ); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_page_title.js b/browser/components/sessionstore/test/browser_page_title.js new file mode 100644 index 0000000000..2fab462de8 --- /dev/null +++ b/browser/components/sessionstore/test/browser_page_title.js @@ -0,0 +1,54 @@ +"use strict"; + +const URL = "data:text/html,<title>initial title</title>"; + +add_task(async function() { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + await promiseBrowserLoaded(tab.linkedBrowser); + + // Remove the tab. + await promiseRemoveTabAndSessionState(tab); + + // Check the title. + let [ + { + state: { entries }, + }, + ] = ss.getClosedTabData(window); + is(entries[0].title, "initial title", "correct title"); +}); + +add_task(async function() { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to ensure we collected the initial title. + await TabStateFlusher.flush(browser); + + // Set a new title. + await SpecialPowers.spawn(browser, [], async function() { + return new Promise(resolve => { + docShell.chromeEventHandler.addEventListener( + "DOMTitleChanged", + () => resolve(), + { once: true } + ); + + content.document.title = "new title"; + }); + }); + + // Remove the tab. + await promiseRemoveTabAndSessionState(tab); + + // Check the title. + let [ + { + state: { entries }, + }, + ] = ss.getClosedTabData(window); + is(entries[0].title, "new title", "correct title"); +}); diff --git a/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js b/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js new file mode 100644 index 0000000000..abc1a538bd --- /dev/null +++ b/browser/components/sessionstore/test/browser_parentProcessRestoreHash.js @@ -0,0 +1,115 @@ +"use strict"; + +const SELFCHROMEURL = + "chrome://mochitests/content/browser/browser/" + + "components/sessionstore/test/browser_parentProcessRestoreHash.js"; + +const Cm = Components.manager; + +const TESTCLASSID = "78742c04-3630-448c-9be3-6c5070f062de"; + +const TESTURL = "about:testpageforsessionrestore#foo"; + +let TestAboutPage = { + QueryInterface: ChromeUtils.generateQI(["nsIAboutModule"]), + getURIFlags(aURI) { + // No CAN_ or MUST_LOAD_IN_CHILD means this loads in the parent: + return ( + Ci.nsIAboutModule.ALLOW_SCRIPT | + Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT | + Ci.nsIAboutModule.HIDE_FROM_ABOUTABOUT + ); + }, + + newChannel(aURI, aLoadInfo) { + // about: page inception! + let newURI = Services.io.newURI(SELFCHROMEURL); + let channel = Services.io.newChannelFromURIWithLoadInfo(newURI, aLoadInfo); + channel.originalURI = aURI; + return channel; + }, + + createInstance(iid) { + return this.QueryInterface(iid); + }, + + register() { + Cm.QueryInterface(Ci.nsIComponentRegistrar).registerFactory( + Components.ID(TESTCLASSID), + "Only here for a test", + "@mozilla.org/network/protocol/about;1?what=testpageforsessionrestore", + this + ); + }, + + unregister() { + Cm.QueryInterface(Ci.nsIComponentRegistrar).unregisterFactory( + Components.ID(TESTCLASSID), + this + ); + }, +}; + +/** + * Test that switching from a remote to a parent process browser + * correctly clears the userTypedValue + */ +add_task(async function() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.skip_about_page_has_csp_assert", true]], + }); + + TestAboutPage.register(); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/", + true, + true + ); + ok(tab.linkedBrowser.isRemoteBrowser, "Browser should be remote"); + + let resolveLocationChangePromise; + let locationChangePromise = new Promise( + r => (resolveLocationChangePromise = r) + ); + let wpl = { + onStateChange(listener, request, state, status) { + let location = request.QueryInterface(Ci.nsIChannel).originalURI; + // Ignore about:blank loads. + let docStop = + Ci.nsIWebProgressListener.STATE_STOP | + Ci.nsIWebProgressListener.STATE_IS_NETWORK; + if (location.spec == "about:blank" || (state & docStop) != docStop) { + return; + } + is(location.spec, TESTURL, "Got the expected URL"); + resolveLocationChangePromise(); + }, + }; + gBrowser.addProgressListener(wpl); + + gURLBar.value = TESTURL; + gURLBar.select(); + EventUtils.sendKey("return"); + + await locationChangePromise; + + ok(!tab.linkedBrowser.isRemoteBrowser, "Browser should no longer be remote"); + + is(gURLBar.value, TESTURL, "URL bar visible value should be correct."); + is(gURLBar.untrimmedValue, TESTURL, "URL bar value should be correct."); + is( + gURLBar.getAttribute("pageproxystate"), + "valid", + "URL bar is in valid page proxy state" + ); + + ok( + !tab.linkedBrowser.userTypedValue, + "No userTypedValue should be on the browser." + ); + + BrowserTestUtils.removeTab(tab); + gBrowser.removeProgressListener(wpl); + TestAboutPage.unregister(); +}); diff --git a/browser/components/sessionstore/test/browser_pending_tabs.js b/browser/components/sessionstore/test/browser_pending_tabs.js new file mode 100644 index 0000000000..617cddf5e1 --- /dev/null +++ b/browser/components/sessionstore/test/browser_pending_tabs.js @@ -0,0 +1,38 @@ +"use strict"; + +const TAB_STATE = { + entries: [ + { url: "about:mozilla", triggeringPrincipal_base64 }, + { url: "about:robots", triggeringPrincipal_base64 }, + ], + index: 1, +}; + +add_task(async function() { + // Create a background tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // The tab shouldn't be restored right away. + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + + // Prepare the tab state. + let promise = promiseTabRestoring(tab); + ss.setTabState(tab, JSON.stringify(TAB_STATE)); + ok(tab.hasAttribute("pending"), "tab is pending"); + await promise; + + // Flush to ensure the parent has all data. + await TabStateFlusher.flush(browser); + + // Check that the shistory index is the one we restored. + let tabState = TabState.collect(tab, ss.getInternalObjectState(tab)); + is(tabState.index, TAB_STATE.index, "correct shistory index"); + + // Check we don't collect userTypedValue when we shouldn't. + ok(!tabState.userTypedValue, "tab didn't have a userTypedValue"); + + // Cleanup. + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_pinned_tabs.js b/browser/components/sessionstore/test/browser_pinned_tabs.js new file mode 100644 index 0000000000..a4d1cce85b --- /dev/null +++ b/browser/components/sessionstore/test/browser_pinned_tabs.js @@ -0,0 +1,347 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const REMOTE_URL = "https://www.example.com/"; +const ABOUT_ROBOTS_URL = "about:robots"; +const NO_TITLE_URL = "data:text/plain,foo"; + +const BACKUP_STATE = SessionStore.getBrowserState(); +registerCleanupFunction(() => promiseBrowserState(BACKUP_STATE)); + +/** + * This is regrettable, but when `promiseBrowserState` resolves, we're still + * midway through loading the tabs. To avoid race conditions in URLs for tabs + * being available, wait for all the loads to finish: + */ +function promiseSessionStoreLoads(numberOfLoads) { + let loadsSeen = 0; + return new Promise(resolve => { + Services.obs.addObserver(function obs(browser) { + loadsSeen++; + if (loadsSeen == numberOfLoads) { + resolve(); + } + // The typeof check is here to avoid one test messing with everything else by + // keeping the observer indefinitely. + if (typeof info == "undefined" || loadsSeen >= numberOfLoads) { + Services.obs.removeObserver(obs, "sessionstore-debug-tab-restored"); + } + info("Saw load for " + browser.currentURI.spec); + }, "sessionstore-debug-tab-restored"); + }); +} + +add_setup(async function() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.sessionstore.restore_on_demand", true], + ["browser.sessionstore.restore_tabs_lazily", true], + ], + }); +}); + +/** + * When implementing batch insertion of tabs as part of session restore, + * we started reversing the insertion order of pinned tabs (bug 1607441). + * This test checks we don't regress that again. + */ +add_task(async function test_pinned_tabs_order() { + // we expect 3 pinned tabs plus the selected tab get content restored. + let allTabsRestored = promiseSessionStoreLoads(4); + await promiseBrowserState({ + windows: [ + { + selected: 4, // SessionStore uses 1-based indexing. + tabs: [ + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: ABOUT_ROBOTS_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: NO_TITLE_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + }); + await allTabsRestored; + let [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + ok(tab1.pinned, "First tab is pinned"); + ok(tab2.pinned, "Second tab is pinned"); + ok(tab3.pinned, "Third tab is pinned"); + ok(!tab4.pinned, "Fourth tab is not pinned"); + ok(!tab5.pinned, "Fifth tab is not pinned"); + + ok(tab4.selected, "Fourth tab is selected"); + is( + tab1.linkedBrowser.currentURI.spec, + REMOTE_URL, + "First tab has matching URL" + ); + is( + tab2.linkedBrowser.currentURI.spec, + ABOUT_ROBOTS_URL, + "Second tab has matching URL" + ); + is( + tab3.linkedBrowser.currentURI.spec, + NO_TITLE_URL, + "Third tab has matching URL" + ); + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); + +/** + * When fixing the previous regression, pinned tabs started disappearing out + * of sessions with selected pinned tabs. This test checks that case. + */ +add_task(async function test_selected_pinned_tab_dataloss() { + // we expect 3 pinned tabs (one of which is selected) get content restored. + let allTabsRestored = promiseSessionStoreLoads(3); + await promiseBrowserState({ + windows: [ + { + selected: 1, // SessionStore uses 1-based indexing. + tabs: [ + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: ABOUT_ROBOTS_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: NO_TITLE_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + }); + await allTabsRestored; + let [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + ok(tab5, "Should have 5 tabs"); + ok(tab1.pinned, "First tab is pinned"); + ok(tab2.pinned, "Second tab is pinned"); + ok(tab3.pinned, "Third tab is pinned"); + ok(tab4 && !tab4.pinned, "Fourth tab is not pinned"); + ok(tab5 && !tab5.pinned, "Fifth tab is not pinned"); + + ok(tab1 && tab1.selected, "First (pinned) tab is selected"); + is( + tab1.linkedBrowser.currentURI.spec, + REMOTE_URL, + "First tab has matching URL" + ); + is( + tab2.linkedBrowser.currentURI.spec, + ABOUT_ROBOTS_URL, + "Second tab has matching URL" + ); + is( + tab3.linkedBrowser.currentURI.spec, + NO_TITLE_URL, + "Third tab has matching URL" + ); + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); + +/** + * While we're here, it seems useful to have a test for mixed pinned and + * unpinned tabs in session store state, as well as userContextId. + */ +add_task(async function test_mixed_pinned_unpinned() { + // we expect 3 pinned tabs plus the selected tab get content restored. + let allTabsRestored = promiseSessionStoreLoads(4); + await promiseBrowserState({ + windows: [ + { + selected: 4, // SessionStore uses 1-based indexing. + tabs: [ + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { + pinned: true, + entries: [{ url: ABOUT_ROBOTS_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { + pinned: true, + entries: [{ url: NO_TITLE_URL, triggeringPrincipal_base64 }], + }, + ], + }, + ], + }); + await allTabsRestored; + let [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + ok(tab1.pinned, "First tab is pinned"); + ok(tab2.pinned, "Second tab is pinned"); + ok(tab3.pinned, "Third tab is pinned"); + ok(!tab4.pinned, "Fourth tab is not pinned"); + ok(!tab5.pinned, "Fifth tab is not pinned"); + + // This is confusing to read - the 4th entry in the session data is + // selected. But the 5th entry is pinned, so it moves to the start of the + // tabstrip, so when we fetch `gBrowser.tabs`, the 4th entry in the list + // is actually the 5th tab. + ok(tab5.selected, "Fifth tab is selected"); + is( + tab1.linkedBrowser.currentURI.spec, + REMOTE_URL, + "First tab has matching URL" + ); + is( + tab2.linkedBrowser.currentURI.spec, + ABOUT_ROBOTS_URL, + "Second tab has matching URL" + ); + is( + tab3.linkedBrowser.currentURI.spec, + NO_TITLE_URL, + "Third tab has matching URL" + ); + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); + +/** + * After session restore, if we crash an unpinned tab, we noticed pinned tabs + * created in the same process would lose all data (Bug 1624511). This test + * checks that case. + */ +add_task(async function test_pinned_tab_dataloss() { + // We do not run if there are no crash reporters to avoid + // problems with the intentional crash. + if (!AppConstants.MOZ_CRASHREPORTER) { + return; + } + // If we end up increasing the process count limit in future, + // we want to ensure that we don't stop testing this case + // of pinned tab data loss. + if (SpecialPowers.getIntPref("dom.ipc.processCount") > 8) { + ok( + false, + "Process count is greater than 8, update the number of pinned tabs in test." + ); + } + + // We expect 17 pinned tabs plus the selected tab get content restored. + // Given that the default process count is currently 8, we need this + // number of pinned tabs to reproduce the data loss. If this changes, + // please add more pinned tabs. + let allTabsRestored = promiseSessionStoreLoads(18); + await promiseBrowserState({ + windows: [ + { + selected: 18, // SessionStore uses 1-based indexing. + tabs: [ + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { + pinned: true, + entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }], + }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + ], + }, + ], + }); + await allTabsRestored; + + let tabs = gBrowser.tabs; + BrowserTestUtils.crashFrame(tabs[17].linkedBrowser); + + await TestUtils.topicObserved("sessionstore-state-write-complete"); + + for (let i = 0; i < tabs.length; i++) { + let tab = tabs[i]; + is( + tab.linkedBrowser.currentURI.spec, + REMOTE_URL, + `Tab ${i + 1} should have matching URL` + ); + } + + // Clean up for the next task. + await promiseBrowserState(BACKUP_STATE); +}); diff --git a/browser/components/sessionstore/test/browser_privatetabs.js b/browser/components/sessionstore/test/browser_privatetabs.js new file mode 100644 index 0000000000..b0616412e0 --- /dev/null +++ b/browser/components/sessionstore/test/browser_privatetabs.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(function cleanup() { + info("Forgetting closed tabs"); + while (ss.getClosedTabCount(window)) { + ss.forgetClosedTab(window, 0); + } +}); + +add_task(async function() { + // Clear the list of closed windows. + forgetClosedWindows(); + + // Create a new window to attach our frame script to. + let win = await promiseNewWindowLoaded({ private: true }); + + // Create a new tab in the new window that will load the frame script. + let tab = BrowserTestUtils.addTab(win.gBrowser, "about:mozilla"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + await TabStateFlusher.flush(browser); + + // Check that we consider the tab as private. + let state = JSON.parse(ss.getTabState(tab)); + ok(state.isPrivate, "tab considered private"); + + // Ensure that closed tabs in a private windows can be restored. + win.gBrowser.removeTab(tab); + is(ss.getClosedTabCount(win), 1, "there is a single tab to restore"); + + // Ensure that closed private windows can never be restored. + await BrowserTestUtils.closeWindow(win); + is(ss.getClosedWindowCount(), 0, "no windows to restore"); +}); diff --git a/browser/components/sessionstore/test/browser_purge_shistory.js b/browser/components/sessionstore/test/browser_purge_shistory.js new file mode 100644 index 0000000000..8dc24b27e9 --- /dev/null +++ b/browser/components/sessionstore/test/browser_purge_shistory.js @@ -0,0 +1,65 @@ +"use strict"; + +/** + * This test checks that pending tabs are treated like fully loaded tabs when + * purging session history. Just like for fully loaded tabs we want to remove + * every but the current shistory entry. + */ + +const TAB_STATE = { + entries: [ + { url: "about:mozilla", triggeringPrincipal_base64 }, + { url: "about:robots", triggeringPrincipal_base64 }, + ], + index: 1, +}; + +function checkTabContents(browser) { + return SpecialPowers.spawn(browser, [], async function() { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + Assert.ok( + history && + history.count == 1 && + content.document.documentURI == "about:mozilla", + "expected tab contents found" + ); + }); +} + +add_task(async function() { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + await promiseTabState(tab, TAB_STATE); + + // Create another new tab. + let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser2 = tab2.linkedBrowser; + await promiseBrowserLoaded(browser2); + + // The tab shouldn't be restored right away. + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + + // Prepare the tab state. + let promise = promiseTabRestoring(tab2); + ss.setTabState(tab2, JSON.stringify(TAB_STATE)); + ok(tab2.hasAttribute("pending"), "tab is pending"); + await promise; + + // Purge session history. + Services.obs.notifyObservers(null, "browser:purge-session-history"); + await checkTabContents(browser); + ok(tab2.hasAttribute("pending"), "tab is still pending"); + + // Kick off tab restoration. + gBrowser.selectedTab = tab2; + await promiseTabRestored(tab2); + await checkTabContents(browser2); + ok(!tab2.hasAttribute("pending"), "tab is not pending anymore"); + + // Cleanup. + gBrowser.removeTab(tab2); + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_remoteness_flip_on_restore.js b/browser/components/sessionstore/test/browser_remoteness_flip_on_restore.js new file mode 100644 index 0000000000..722fd9f9b3 --- /dev/null +++ b/browser/components/sessionstore/test/browser_remoteness_flip_on_restore.js @@ -0,0 +1,310 @@ +"use strict"; + +/** + * This set of tests checks that the remoteness is properly + * set for each browser in a window when that window has + * session state loaded into it. + */ + +/** + * Takes a SessionStore window state object for a single + * window, sets the selected tab for it, and then returns + * the object to be passed to SessionStore.setWindowState. + * + * @param state (object) + * The state to prepare to be sent to a window. This is + * state should just be for a single window. + * @param selected (int) + * The 1-based index of the selected tab. Note that + * If this is 0, then the selected tab will not change + * from what's already selected in the window that we're + * sending state to. + * @returns (object) + * The JSON encoded string to call + * SessionStore.setWindowState with. + */ +function prepareState(state, selected) { + // We'll create a copy so that we don't accidentally + // modify the caller's selected property. + let copy = {}; + Object.assign(copy, state); + copy.selected = selected; + + return { + windows: [copy], + }; +} + +const SIMPLE_STATE = { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + ], + title: "", + _closedTabs: [], +}; + +const PINNED_STATE = { + tabs: [ + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + pinned: true, + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + pinned: true, + }, + { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "title", + }, + ], + }, + ], + title: "", + _closedTabs: [], +}; + +/** + * This is where most of the action is happening. This function takes + * an Array of "test scenario" Objects and runs them. For each scenario, a + * window is opened, put into some state, and then a new state is + * loaded into that window. We then check to make sure that the + * right things have happened in that window wrt remoteness flips. + * + * The schema for a testing scenario Object is as follows: + * + * initialRemoteness: + * an Array that represents the starting window. Each bool + * in the Array represents the window tabs in order. A "true" + * indicates that that tab should be remote. "false" if the tab + * should be non-remote. + * + * initialSelectedTab: + * The 1-based index of the tab that we want to select for the + * restored window. This is 1-based to avoid confusion with the + * selectedTab property described down below, though you probably + * want to set this to be greater than 0, since the initial window + * needs to have a defined initial selected tab. Because of this, + * the test will throw if initialSelectedTab is 0. + * + * stateToRestore: + * A JS Object for the state to send down to the window. + * + * selectedTab: + * The 1-based index of the tab that we want to select for the + * restored window. Leave this at 0 if you don't want to change + * the selection from the initial window state. + * + * expectedRemoteness: + * an Array that represents the window that we end up with after + * restoring state. Each bool in the Array represents the window + * tabs in order. A "true" indicates that the tab be remote, and + * a "false" indicates that the tab should be "non-remote". We + * need this Array in order to test pinned tabs which will also + * be loaded by default, and therefore should end up remote. + * + */ +async function runScenarios(scenarios) { + for (let [scenarioIndex, scenario] of scenarios.entries()) { + info("Running scenario " + scenarioIndex); + Assert.ok( + scenario.initialSelectedTab > 0, + "You must define an initially selected tab" + ); + + // First, we need to create the initial conditions, so we + // open a new window to put into our starting state... + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tabbrowser = win.gBrowser; + Assert.ok( + tabbrowser.selectedBrowser.isRemoteBrowser, + "The initial browser should be remote." + ); + // Now put the window into the expected initial state. + for (let i = 0; i < scenario.initialRemoteness.length; ++i) { + let tab; + if (i > 0) { + // The window starts with one tab, so we need to create + // any of the additional ones required by this test. + info("Opening a new tab"); + tab = await BrowserTestUtils.openNewForegroundTab(tabbrowser); + } else { + info("Using the selected tab"); + tab = tabbrowser.selectedTab; + } + let browser = tab.linkedBrowser; + let remotenessState = scenario.initialRemoteness[i] + ? E10SUtils.DEFAULT_REMOTE_TYPE + : E10SUtils.NOT_REMOTE; + tabbrowser.updateBrowserRemoteness(browser, { + remoteType: remotenessState, + }); + } + + // And select the requested tab. + let tabToSelect = tabbrowser.tabs[scenario.initialSelectedTab - 1]; + if (tabbrowser.selectedTab != tabToSelect) { + await BrowserTestUtils.switchTab(tabbrowser, tabToSelect); + } + + // Okay, time to test! + let state = prepareState(scenario.stateToRestore, scenario.selectedTab); + + await setWindowState(win, state, true); + + for (let i = 0; i < scenario.expectedRemoteness.length; ++i) { + let expectedRemoteness = scenario.expectedRemoteness[i]; + let tab = tabbrowser.tabs[i]; + + Assert.equal( + tab.linkedBrowser.isRemoteBrowser, + expectedRemoteness, + "Should have gotten the expected remoteness " + + `for the tab at index ${i}` + ); + } + + await BrowserTestUtils.closeWindow(win); + } +} + +/** + * Tests that if we restore state to browser windows with + * a variety of initial remoteness states. For this particular + * set of tests, we assume that tabs are restoring on demand. + */ +add_task(async function() { + // This test opens and closes windows, which might bog down + // a debug build long enough to time out the test, so we + // extend the tolerance on timeouts. + requestLongerTimeout(5); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.restore_on_demand", true]], + }); + + const TEST_SCENARIOS = [ + // Only one tab in the new window, and it's remote. This + // is the common case, since this is how restoration occurs + // when the restored window is being opened. + { + initialRemoteness: [true], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 3, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // A single remote tab, and this is the one that's going + // to be selected once state is restored. + { + initialRemoteness: [true], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 1, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // A single remote tab which starts selected. We set the + // selectedTab to 0 which is equivalent to "don't change + // the tab selection in the window". + { + initialRemoteness: [true], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 0, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // An initially remote tab, but we're going to load + // some pinned tabs now, and the pinned tabs should load + // right away. + { + initialRemoteness: [true], + initialSelectedTab: 1, + stateToRestore: PINNED_STATE, + selectedTab: 3, + // Both pinned tabs and the selected tabs should all + // end up being remote. + expectedRemoteness: [true, true, true], + }, + + // A single non-remote tab. + { + initialRemoteness: [false], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 2, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // A mixture of remote and non-remote tabs. + { + initialRemoteness: [true, false, true], + initialSelectedTab: 1, + stateToRestore: SIMPLE_STATE, + selectedTab: 3, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + + // An initially non-remote tab, but we're going to load + // some pinned tabs now, and the pinned tabs should load + // right away. + { + initialRemoteness: [false], + initialSelectedTab: 1, + stateToRestore: PINNED_STATE, + selectedTab: 3, + // All tabs should now be remote. + expectedRemoteness: [true, true, true], + }, + ]; + + await runScenarios(TEST_SCENARIOS); +}); diff --git a/browser/components/sessionstore/test/browser_reopen_all_windows.js b/browser/components/sessionstore/test/browser_reopen_all_windows.js new file mode 100644 index 0000000000..532e689f50 --- /dev/null +++ b/browser/components/sessionstore/test/browser_reopen_all_windows.js @@ -0,0 +1,146 @@ +"use strict"; + +const PATH = "browser/browser/components/sessionstore/test/empty.html"; +var URLS_WIN1 = [ + "https://example.com/" + PATH, + "https://example.org/" + PATH, + "http://test1.mochi.test:8888/" + PATH, + "http://test1.example.com/" + PATH, +]; +var EXPECTED_URLS_WIN1 = ["about:blank", ...URLS_WIN1]; + +var URLS_WIN2 = [ + "http://sub1.test1.mochi.test:8888/" + PATH, + "http://sub2.xn--lt-uia.mochi.test:8888/" + PATH, + "http://test2.mochi.test:8888/" + PATH, + "http://sub1.test2.example.org/" + PATH, + "http://sub2.test1.example.org/" + PATH, + "http://test2.example.com/" + PATH, +]; +var EXPECTED_URLS_WIN2 = ["about:blank", ...URLS_WIN2]; + +requestLongerTimeout(4); + +function allTabsRestored(win, expectedUrls) { + return new Promise(resolve => { + let tabsRestored = 0; + function handler(event) { + let spec = event.target.linkedBrowser.currentURI.spec; + if (expectedUrls.includes(spec)) { + tabsRestored++; + } + info(`Got SSTabRestored for ${spec}, tabsRestored=${tabsRestored}`); + if (tabsRestored === expectedUrls.length) { + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + handler, + true + ); + resolve(); + } + } + win.gBrowser.tabContainer.addEventListener("SSTabRestored", handler, true); + }); +} + +async function windowAndTabsRestored(win, expectedUrls) { + await TestUtils.topicObserved( + "browser-window-before-show", + subject => subject === win + ); + return allTabsRestored(win, expectedUrls); +} + +add_task(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + ["browser.sessionstore.restore_on_demand", false], + ], + }); + + forgetClosedWindows(); + is(SessionStore.getClosedWindowCount(), 0, "starting with no closed windows"); + + // Open window 1, with different tabs + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + for (let url of URLS_WIN1) { + await BrowserTestUtils.openNewForegroundTab(win1.gBrowser, url); + } + + // Open window 2, with different tabs + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + for (let url of URLS_WIN2) { + await BrowserTestUtils.openNewForegroundTab(win2.gBrowser, url); + } + + await TabStateFlusher.flushWindow(win1); + await TabStateFlusher.flushWindow(win2); + + // Close both windows + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + await forceSaveState(); + + // Verify both windows were accounted for by session store + is( + ss.getClosedWindowCount(), + 2, + "The closed windows was added to Recently Closed Windows" + ); + + // We previously used to manually navigate the Library menu to click the + // "Reopen all Windows" button, but that reopens all windows at once without + // returning a reference to each window. Since we need to attach listeners to + // these windows *before* they start restoring tabs, we now manually call + // undoCloseWindow() here, which has the same effect, but also gives us the + // window references. + info("Reopening windows"); + let restoredWindows = []; + while (SessionStore.getClosedWindowCount() > 0) { + restoredWindows.unshift(undoCloseWindow()); + } + is(restoredWindows.length, 2, "Reopened correct number of windows"); + + let win1Restored = windowAndTabsRestored( + restoredWindows[0], + EXPECTED_URLS_WIN1 + ); + let win2Restored = windowAndTabsRestored( + restoredWindows[1], + EXPECTED_URLS_WIN2 + ); + + info("About to wait for tabs to be restored"); + await Promise.all([win1Restored, win2Restored]); + + is( + restoredWindows[0].gBrowser.tabs.length, + EXPECTED_URLS_WIN1.length, + "All tabs restored" + ); + is( + restoredWindows[1].gBrowser.tabs.length, + EXPECTED_URLS_WIN2.length, + "All tabs restored" + ); + + // Verify that tabs opened as expected + Assert.deepEqual( + restoredWindows[0].gBrowser.tabs.map( + tab => tab.linkedBrowser.currentURI.spec + ), + EXPECTED_URLS_WIN1 + ); + Assert.deepEqual( + restoredWindows[1].gBrowser.tabs.map( + tab => tab.linkedBrowser.currentURI.spec + ), + EXPECTED_URLS_WIN2 + ); + + info("About to close windows"); + await BrowserTestUtils.closeWindow(restoredWindows[0]); + await BrowserTestUtils.closeWindow(restoredWindows[1]); +}); diff --git a/browser/components/sessionstore/test/browser_replace_load.js b/browser/components/sessionstore/test/browser_replace_load.js new file mode 100644 index 0000000000..64df429b63 --- /dev/null +++ b/browser/components/sessionstore/test/browser_replace_load.js @@ -0,0 +1,55 @@ +"use strict"; + +const STATE = { + entries: [{ url: "about:robots" }, { url: "about:mozilla" }], + selected: 2, +}; + +/** + * Bug 1100223. Calling browser.loadURI() while a tab is loading causes + * sessionstore to override the desired target URL. This test ensures that + * calling loadURI() on a pending tab causes the tab to no longer be marked + * as pending and correctly finish the instructed load while keeping the + * restored history around. + */ +add_task(async function() { + await testSwitchToTab("about:mozilla#fooobar", { + ignoreFragment: "whenComparingAndReplace", + }); + await testSwitchToTab("about:mozilla?foo=bar", { replaceQueryString: true }); +}); + +var testSwitchToTab = async function(url, options) { + // Create a background tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // The tab shouldn't be restored right away. + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + + // Prepare the tab state. + let promise = promiseTabRestoring(tab); + ss.setTabState(tab, JSON.stringify(STATE)); + ok(tab.hasAttribute("pending"), "tab is pending"); + await promise; + + options.triggeringPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + + // Switch-to-tab with a similar URI. + switchToTabHavingURI(url, false, options); + + // Tab should now restore + await promiseTabRestored(tab); + is(browser.currentURI.spec, url, "correct URL loaded"); + + // Check that we didn't lose any history entries. + await SpecialPowers.spawn(browser, [], async function() { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + Assert.equal(history && history.count, 3, "three history entries"); + }); + + // Cleanup. + gBrowser.removeTab(tab); +}; diff --git a/browser/components/sessionstore/test/browser_restoreTabContainer.js b/browser/components/sessionstore/test/browser_restoreTabContainer.js new file mode 100644 index 0000000000..40fef00ff6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restoreTabContainer.js @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" +); + +add_task(async function() { + const testUserContextId = 2; + const testCases = [ + { + url: `${TEST_PATH}empty.html`, + crossOriginIsolated: false, + }, + { + url: `${TEST_PATH}coop_coep.html`, + crossOriginIsolated: true, + }, + ]; + + for (const testCase of testCases) { + let tab = BrowserTestUtils.addTab(gBrowser, testCase.url, { + userContextId: testUserContextId, + }); + await promiseBrowserLoaded(tab.linkedBrowser); + + is( + tab.userContextId, + testUserContextId, + `The tab was opened with the expected userContextId` + ); + + await SpecialPowers.spawn( + tab.linkedBrowser, + [testCase.crossOriginIsolated], + async expectedCrossOriginIsolated => { + is( + content.window.crossOriginIsolated, + expectedCrossOriginIsolated, + `The tab was opened in the expected crossOriginIsolated environment` + ); + } + ); + + let sessionPromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionPromise; + + let restoredTab = SessionStore.undoCloseTab(window, 0); + + // TODO: also check that `promiseTabRestored` is fulfilled. This currently + // doesn't happen correctly in some cases, as the content restore is aborted + // when the process switch occurs to load a cross-origin-isolated document + // into a different process. + await promiseBrowserLoaded(restoredTab.linkedBrowser); + + is( + restoredTab.userContextId, + testUserContextId, + `The tab was restored with the expected userContextId` + ); + + await SpecialPowers.spawn( + restoredTab.linkedBrowser, + [testCase.crossOriginIsolated], + async expectedCrossOriginIsolated => { + is( + content.window.crossOriginIsolated, + expectedCrossOriginIsolated, + `The tab was restored in the expected crossOriginIsolated environment` + ); + } + ); + + BrowserTestUtils.removeTab(restoredTab); + } +}); diff --git a/browser/components/sessionstore/test/browser_restore_container_tabs_oa.js b/browser/components/sessionstore/test/browser_restore_container_tabs_oa.js new file mode 100644 index 0000000000..fc22ab917f --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_container_tabs_oa.js @@ -0,0 +1,231 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const PATH = "browser/browser/components/sessionstore/test/empty.html"; + +/* import-globals-from ../../../base/content/test/tabs/helper_origin_attrs_testing.js */ +loadTestSubscript( + "../../../base/content/test/tabs/helper_origin_attrs_testing.js" +); + +var TEST_CASES = [ + "https://example.com/" + PATH, + "https://example.org/" + PATH, + "about:preferences", + "about:config", +]; + +var remoteTypes; + +var xulFrameLoaderCreatedCounter = {}; + +function handleEventLocal(aEvent) { + if (aEvent.type != "XULFrameLoaderCreated") { + return; + } + // Ignore <browser> element in about:preferences and any other special pages + if ("gBrowser" in aEvent.target.ownerGlobal) { + xulFrameLoaderCreatedCounter.numCalledSoFar++; + } +} + +var NUM_DIFF_TAB_MODES = NUM_USER_CONTEXTS + 1; /** regular tab */ + +add_setup(async function() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + ["browser.sessionstore.restore_on_demand", true], + // Don't restore tabs lazily. + ["browser.sessionstore.restore_tabs_lazily", true], + // don't preload tabs so we don't have extra XULFrameLoaderCreated events + // firing + ["browser.newtab.preload", false], + ], + }); + + requestLongerTimeout(7); +}); + +function setupRemoteTypes() { + if (gFissionBrowser) { + remoteTypes = [ + "webIsolated=https://example.com", + "webIsolated=https://example.com^userContextId=1", + "webIsolated=https://example.com^userContextId=2", + "webIsolated=https://example.com^userContextId=3", + "webIsolated=https://example.org", + "webIsolated=https://example.org^userContextId=1", + "webIsolated=https://example.org^userContextId=2", + "webIsolated=https://example.org^userContextId=3", + ]; + } else { + remoteTypes = Array( + NUM_DIFF_TAB_MODES * 2 /** 2 is the number of non parent uris */ + ).fill("web"); + } + remoteTypes.push(...Array(NUM_DIFF_TAB_MODES * 2).fill(null)); // remote types for about: pages + + forgetClosedWindows(); + is(SessionStore.getClosedWindowCount(), 0, "starting with no closed windows"); +} +/* + * 1. Open several tabs in different containers and in regular tabs + [page1, page2, page3] [ [(page1 - work) (page1 - home)] [(page2 - work) (page2 - home)] ] + * 2. Close the window + * 3. Restore session, window will have the following tabs + * [initial blank page, page1, page1-work, page1-home, page2, page2-work, page2-home] + * 4. Verify correct remote types and that XULFrameLoaderCreated gets fired correct number of times + */ +add_task(async function testRestore() { + setupRemoteTypes(); + let newWin = await promiseNewWindowLoaded(); + var regularPages = []; + var containerPages = {}; + // Go through all the test cases and open same set of urls in regular tabs and in container tabs + for (const uri of TEST_CASES) { + // Open a url in a regular tab + let regularPage = await openURIInRegularTab(uri, newWin); + regularPages.push(regularPage); + + // Open the same url in different user contexts + for ( + var user_context_id = 1; + user_context_id <= NUM_USER_CONTEXTS; + user_context_id++ + ) { + let containerPage = await openURIInContainer( + uri, + newWin, + user_context_id + ); + containerPages[uri] = containerPage; + } + } + await TabStateFlusher.flushWindow(newWin); + + // Close the window + await BrowserTestUtils.closeWindow(newWin); + await forceSaveState(); + + is( + SessionStore.getClosedWindowCount(), + 1, + "Should have restore data for the closed window" + ); + + // Now restore the window + newWin = SessionStore.undoCloseWindow(0); + + // Make sure to wait for the window to be restored. + await Promise.all([ + BrowserTestUtils.waitForEvent(newWin, "SSWindowStateReady"), + ]); + await BrowserTestUtils.waitForEvent( + newWin.gBrowser.tabContainer, + "SSTabRestored" + ); + + var nonblank_pages_len = + TEST_CASES.length + NUM_USER_CONTEXTS * TEST_CASES.length; + is( + newWin.gBrowser.tabs.length, + nonblank_pages_len + 1 /* initial page */, + "Correct number of tabs restored" + ); + + // Now we have pages opened in the following manner + // [blank page, page1, page1-work, page1-home, page2, page2-work, page2-home] + + info(`Number of tabs restored: ${newWin.gBrowser.tabs.length}`); + var currRemoteType, expectedRemoteType; + let loaded; + for (var tab_idx = 1; tab_idx < nonblank_pages_len; ) { + info(`Accessing regular tab at index ${tab_idx}`); + var test_page_data = regularPages.shift(); + let regular_tab = newWin.gBrowser.tabs[tab_idx]; + let regular_browser = regular_tab.linkedBrowser; + + // I would have used browserLoaded but for about:config it doesn't work + let ready = BrowserTestUtils.waitForCondition(async () => { + // Catch an error because the browser might change remoteness in between + // calls, so we will just wait for the document to finish loadig. + return SpecialPowers.spawn(regular_browser, [], () => { + return content.document.readyState == "complete"; + }).catch(Cu.reportError); + }); + newWin.gBrowser.selectedTab = regular_tab; + await TabStateFlusher.flush(regular_browser); + await ready; + + currRemoteType = regular_browser.remoteType; + expectedRemoteType = remoteTypes.shift(); + is( + currRemoteType, + expectedRemoteType, + `correct remote type for regular tab with uri ${test_page_data.uri}` + ); + + let page_uri = regular_browser.currentURI.spec; + info(`Current uri = ${page_uri}`); + + // Iterate over container pages, starting after the regular page and ending before the next regular page + var userContextId = 1; + for ( + var container_tab_idx = tab_idx + 1; + container_tab_idx < tab_idx + 1 + NUM_USER_CONTEXTS; + container_tab_idx++, userContextId++ + ) { + info(`Accessing container tab at index ${container_tab_idx}`); + let container_tab = newWin.gBrowser.tabs[container_tab_idx]; + + initXulFrameLoaderCreatedCounter(xulFrameLoaderCreatedCounter); + container_tab.ownerGlobal.gBrowser.addEventListener( + "XULFrameLoaderCreated", + handleEventLocal + ); + + loaded = BrowserTestUtils.browserLoaded( + container_tab.linkedBrowser, + false, + test_page_data.uri + ); + + newWin.gBrowser.selectedTab = container_tab; + await TabStateFlusher.flush(container_tab.linkedBrowser); + await loaded; + let uri = container_tab.linkedBrowser.currentURI.spec; + + // Verify XULFrameLoaderCreated was fired once + is( + xulFrameLoaderCreatedCounter.numCalledSoFar, + 1, + `XULFrameLoaderCreated was fired once, when restoring ${uri} in container ${userContextId} ` + ); + container_tab.ownerGlobal.gBrowser.removeEventListener( + "XULFrameLoaderCreated", + handleEventLocal + ); + + // Verify correct remote type for container tab + currRemoteType = container_tab.linkedBrowser.remoteType; + expectedRemoteType = remoteTypes.shift(); + info( + `Remote type for container tab ${userContextId} is ${currRemoteType}` + ); + is( + currRemoteType, + expectedRemoteType, + "correct remote type for container tab" + ); + } + // Advance to the next regular page in our tabs list + tab_idx = container_tab_idx; + } + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/sessionstore/test/browser_restore_cookies_noOriginAttributes.js b/browser/components/sessionstore/test/browser_restore_cookies_noOriginAttributes.js new file mode 100644 index 0000000000..1159f8ef9b --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_cookies_noOriginAttributes.js @@ -0,0 +1,193 @@ +/* + * Bug 1267910 - The regression test case for session cookies. + */ + +"use strict"; + +const TEST_HOST = "www.example.com"; +const COOKIE = { + name: "test1", + value: "yes1", + path: "/browser/browser/components/sessionstore/test/", +}; +const SESSION_DATA = JSON.stringify({ + version: ["sessionrestore", 1], + windows: [ + { + tabs: [ + { + entries: [], + lastAccessed: 1463893009797, + hidden: false, + attributes: {}, + image: null, + }, + { + entries: [ + { + url: + "http://www.example.com/browser/browser/components/sessionstore/test/browser_1267910_page.html", + triggeringPrincipal_base64, + charset: "UTF-8", + ID: 0, + docshellID: 2, + originalURI: + "http://www.example.com/browser/browser/components/sessionstore/test/browser_1267910_page.html", + docIdentifier: 0, + persist: true, + }, + ], + lastAccessed: 1463893009321, + hidden: false, + attributes: {}, + userContextId: 0, + index: 1, + image: "http://www.example.com/favicon.ico", + }, + ], + selected: 1, + _closedTabs: [], + busy: false, + width: 1024, + height: 768, + screenX: 4, + screenY: 23, + sizemode: "normal", + cookies: [ + { + host: "www.example.com", + value: "yes1", + path: "/browser/browser/components/sessionstore/test/", + name: "test1", + }, + ], + }, + ], + selectedWindow: 1, + _closedWindows: [], + session: { + lastUpdate: 1463893009801, + startTime: 1463893007134, + recentCrashes: 0, + }, + global: {}, +}); + +const SESSION_DATA_OA = JSON.stringify({ + version: ["sessionrestore", 1], + windows: [ + { + tabs: [ + { + entries: [], + lastAccessed: 1463893009797, + hidden: false, + attributes: {}, + image: null, + }, + { + entries: [ + { + url: + "http://www.example.com/browser/browser/components/sessionstore/test/browser_1267910_page.html", + triggeringPrincipal_base64, + charset: "UTF-8", + ID: 0, + docshellID: 2, + originalURI: + "http://www.example.com/browser/browser/components/sessionstore/test/browser_1267910_page.html", + docIdentifier: 0, + persist: true, + }, + ], + lastAccessed: 1463893009321, + hidden: false, + attributes: {}, + userContextId: 0, + index: 1, + image: "http://www.example.com/favicon.ico", + }, + ], + selected: 1, + _closedTabs: [], + busy: false, + width: 1024, + height: 768, + screenX: 4, + screenY: 23, + sizemode: "normal", + cookies: [ + { + host: "www.example.com", + value: "yes1", + path: "/browser/browser/components/sessionstore/test/", + name: "test1", + originAttributes: { + addonId: "", + inIsolatedMozBrowser: false, + userContextId: 0, + }, + }, + ], + }, + ], + selectedWindow: 1, + _closedWindows: [], + session: { + lastUpdate: 1463893009801, + startTime: 1463893007134, + recentCrashes: 0, + }, + global: {}, +}); + +add_task(async function run_test() { + // Wait until initialization is complete. + await SessionStore.promiseInitialized; + + // Clear cookies. + Services.cookies.removeAll(); + + // Open a new window. + let win = await promiseNewWindowLoaded(); + + // Restore window with session cookies that have no originAttributes. + await setWindowState(win, SESSION_DATA, true); + + let cookieCount = 0; + for (var cookie of Services.cookies.getCookiesFromHost(TEST_HOST, {})) { + cookieCount++; + } + + // Check that the cookie is restored successfully. + is(cookieCount, 1, "expected one cookie"); + is(cookie.name, COOKIE.name, "cookie name successfully restored"); + is(cookie.value, COOKIE.value, "cookie value successfully restored"); + is(cookie.path, COOKIE.path, "cookie path successfully restored"); + + // Clear cookies. + Services.cookies.removeAll(); + + // In real usage, the event loop would get to spin between setWindowState + // uses. Without a spin, we can defer handling the STATE_STOP that + // removes the progress listener until after the mozbrowser has been + // destroyed, causing a window leak. + await new Promise(resolve => win.setTimeout(resolve, 0)); + + // Restore window with session cookies that have originAttributes within. + await setWindowState(win, SESSION_DATA_OA, true); + + cookieCount = 0; + for (cookie of Services.cookies.getCookiesFromHost(TEST_HOST, {})) { + cookieCount++; + } + + // Check that the cookie is restored successfully. + is(cookieCount, 1, "expected one cookie"); + is(cookie.name, COOKIE.name, "cookie name successfully restored"); + is(cookie.value, COOKIE.value, "cookie value successfully restored"); + is(cookie.path, COOKIE.path, "cookie path successfully restored"); + + // Close our window. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_restore_pageProxyState.js b/browser/components/sessionstore/test/browser_restore_pageProxyState.js new file mode 100644 index 0000000000..f98237c7e8 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_pageProxyState.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const BACKUP_STATE = SessionStore.getBrowserState(); +registerCleanupFunction(() => promiseBrowserState(BACKUP_STATE)); + +// The pageproxystate of the restored tab controls whether the identity +// information in the URL bar will display correctly. See bug 1766951 for more +// context. +async function test_pageProxyState(url1, url2) { + info(`urls: "${url1}", "${url2}"`); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.sessionstore.restore_on_demand", true], + ["browser.sessionstore.restore_tabs_lazily", true], + ], + }); + + await promiseBrowserState({ + windows: [ + { + tabs: [ + { + entries: [ + { + url: url1, + triggeringPrincipal_base64, + }, + ], + }, + { + entries: [ + { + url: url2, + triggeringPrincipal_base64, + }, + ], + }, + ], + selected: 1, + }, + ], + }); + + // The first tab isn't lazy and should be initialized. + ok(gBrowser.tabs[0].linkedPanel, "first tab is not lazy"); + is(gBrowser.selectedTab, gBrowser.tabs[0], "first tab is selected"); + is(gBrowser.userTypedValue, null, "no user typed value"); + is( + gURLBar.getAttribute("pageproxystate"), + "valid", + "has valid page proxy state" + ); + + // The second tab is lazy until selected. + ok(!gBrowser.tabs[1].linkedPanel, "second tab should be lazy"); + gBrowser.selectedTab = gBrowser.tabs[1]; + await promiseTabRestored(gBrowser.tabs[1]); + is(gBrowser.userTypedValue, null, "no user typed value"); + is( + gURLBar.getAttribute("pageproxystate"), + "valid", + "has valid page proxy state" + ); +} + +add_task(async function test_system() { + await test_pageProxyState("about:support", "about:addons"); +}); + +add_task(async function test_http() { + await test_pageProxyState( + "https://example.com/document-builder.sjs?html=tab1", + "https://example.com/document-builder.sjs?html=tab2" + ); +}); diff --git a/browser/components/sessionstore/test/browser_restore_private_tab_os.js b/browser/components/sessionstore/test/browser_restore_private_tab_os.js new file mode 100644 index 0000000000..feecfe327a --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_private_tab_os.js @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const TEST_URI = + "https://example.com/" + + "browser/browser/components/sessionstore/test/empty.html"; + +add_setup(async function() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + ["browser.sessionstore.restore_on_demand", true], + // Don't restore tabs lazily. + ["browser.sessionstore.restore_tabs_lazily", true], + // don't preload tabs so we don't have extra XULFrameLoaderCreated events + // firing + ["browser.newtab.preload", false], + ], + }); +}); + +add_task(async function testRestore() { + // Clear the list of closed windows. + forgetClosedWindows(); + + // Create a new private window + let win = await promiseNewWindowLoaded({ private: true }); + + // Create a new private tab + let tab = BrowserTestUtils.addTab(win.gBrowser, TEST_URI); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + await TabStateFlusher.flush(browser); + + // Ensure that closed tabs in a private windows can be restored. + win.gBrowser.removeTab(tab); + is(ss.getClosedTabCount(win), 1, "there is a single tab to restore"); + + tab = SessionStore.undoCloseTab(win, 0); + info(`Undo close tab`); + browser = tab.linkedBrowser; + await promiseTabRestored(tab); + info(`Private tab restored`); + + let expectedRemoteType = gFissionBrowser + ? "webIsolated=https://example.com^privateBrowsingId=1" + : "web"; + is(browser.remoteType, expectedRemoteType, "correct remote type"); + + await BrowserTestUtils.closeWindow(win); + + // Cleanup + info("Forgetting closed tabs"); + while (ss.getClosedTabCount(window)) { + ss.forgetClosedTab(window, 0); + } +}); diff --git a/browser/components/sessionstore/test/browser_restore_redirect.js b/browser/components/sessionstore/test/browser_restore_redirect.js new file mode 100644 index 0000000000..206b783191 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_redirect.js @@ -0,0 +1,72 @@ +"use strict"; + +const BASE = "http://example.com/browser/browser/components/sessionstore/test/"; +const TARGET = BASE + "restore_redirect_target.html"; + +/** + * Ensure that a http redirect leaves a working tab. + */ +add_task(async function check_http_redirect() { + let state = { + entries: [ + { url: BASE + "restore_redirect_http.html", triggeringPrincipal_base64 }, + ], + }; + + // Open a new tab to restore into. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseTabState(tab, state); + + info("Restored tab"); + + await TabStateFlusher.flush(browser); + let data = TabState.collect(tab); + is(data.entries.length, 1, "Should be one entry in session history"); + is(data.entries[0].url, TARGET, "Should be the right session history entry"); + + ok( + !ss.getInternalObjectState(browser), + "Temporary restore data should have been cleared" + ); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); + +/** + * Ensure that a js redirect leaves a working tab. + */ +add_task(async function check_js_redirect() { + let state = { + entries: [ + { url: BASE + "restore_redirect_js.html", triggeringPrincipal_base64 }, + ], + }; + + // Open a new tab to restore into. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + let loadPromise = BrowserTestUtils.browserLoaded(browser, true, url => + url.endsWith("restore_redirect_target.html") + ); + + await promiseTabState(tab, state); + + info("Restored tab"); + + await loadPromise; + + await TabStateFlusher.flush(browser); + let data = TabState.collect(tab); + is(data.entries.length, 1, "Should be one entry in session history"); + is(data.entries[0].url, TARGET, "Should be the right session history entry"); + + ok( + !ss.getInternalObjectState(browser), + "Temporary restore data should have been cleared" + ); + + // Cleanup. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_restore_reversed_z_order.js b/browser/components/sessionstore/test/browser_restore_reversed_z_order.js new file mode 100644 index 0000000000..298f8ccaab --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_reversed_z_order.js @@ -0,0 +1,125 @@ +"use strict"; + +const PRIMARY_WINDOW = window; + +let gTestURLsMap = new Map([ + ["about:about", null], + ["about:license", null], + ["about:robots", null], + ["about:mozilla", null], +]); +let gBrowserState; + +add_setup(async function() { + let windows = []; + let count = 0; + for (let url of gTestURLsMap.keys()) { + let window = !count + ? PRIMARY_WINDOW + : await BrowserTestUtils.openNewBrowserWindow(); + let browserLoaded = BrowserTestUtils.browserLoaded( + window.gBrowser.selectedBrowser + ); + BrowserTestUtils.loadURI(window.gBrowser.selectedBrowser, url); + await browserLoaded; + // Capture the title. + gTestURLsMap.set(url, window.gBrowser.selectedTab.label); + // Minimize the before-last window, to have a different window feature added + // to the test. + if (count == gTestURLsMap.size - 1) { + let activated = BrowserTestUtils.waitForEvent( + windows[count - 1], + "activate" + ); + window.minimize(); + await activated; + } + windows.push(window); + ++count; + } + + // Wait until we get the lastest history from all windows. + await Promise.all(windows.map(window => TabStateFlusher.flushWindow(window))); + + gBrowserState = ss.getBrowserState(); + + await promiseAllButPrimaryWindowClosed(); +}); + +add_task(async function test_z_indices_are_saved_correctly() { + let state = JSON.parse(gBrowserState); + Assert.equal( + state.windows.length, + gTestURLsMap.size, + "Correct number of windows saved" + ); + + // Check if we saved state in correct order of creation. + let idx = 0; + for (let url of gTestURLsMap.keys()) { + Assert.equal( + state.windows[idx].tabs[0].entries[0].url, + url, + `Window #${idx} is stored in correct creation order` + ); + ++idx; + } + + // Check if we saved a valid zIndex (no null, no undefined or no 0). + for (let window of state.windows) { + Assert.ok(window.zIndex, "A valid zIndex is stored"); + } + + Assert.equal( + state.windows[0].zIndex, + 3, + "Window #1 should have the correct z-index" + ); + Assert.equal( + state.windows[1].zIndex, + 2, + "Window #2 should have correct z-index" + ); + Assert.equal( + state.windows[2].zIndex, + 1, + "Window #3 should be the topmost window" + ); + Assert.equal( + state.windows[3].zIndex, + 4, + "Minimized window should be the last window to restore" + ); +}); + +add_task(async function test_windows_are_restored_in_reversed_z_order() { + await promiseBrowserState(gBrowserState); + + let indexedTabLabels = [...gTestURLsMap.values()]; + let tabsRestoredLabels = BrowserWindowTracker.orderedWindows.map( + window => window.gBrowser.selectedTab.label + ); + + Assert.equal( + tabsRestoredLabels[0], + indexedTabLabels[2], + "First restored tab should be last used tab" + ); + Assert.equal( + tabsRestoredLabels[1], + indexedTabLabels[1], + "Second restored tab is correct" + ); + Assert.equal( + tabsRestoredLabels[2], + indexedTabLabels[0], + "Third restored tab is correct" + ); + Assert.equal( + tabsRestoredLabels[3], + indexedTabLabels[3], + "Last restored tab should be a minimized window" + ); + + await promiseAllButPrimaryWindowClosed(); +}); diff --git a/browser/components/sessionstore/test/browser_restore_srcdoc.js b/browser/components/sessionstore/test/browser_restore_srcdoc.js new file mode 100644 index 0000000000..9e670100ea --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_srcdoc.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function makeURL(srcdocValue) { + return `data:text/html;charset=utf-8,<iframe srcdoc="${srcdocValue}">`; +} + +async function runTest(srcdocValue) { + forgetClosedWindows(); + + // Open a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, makeURL(srcdocValue)); + await promiseBrowserLoaded(tab.linkedBrowser); + + // Close that tab. + await promiseRemoveTabAndSessionState(tab); + + // Restore that tab. + tab = ss.undoCloseTab(window, 0); + await promiseTabRestored(tab); + + // Verify contents were restored correctly. + let iframe = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); + await SpecialPowers.spawn(iframe, [srcdocValue], text => { + Assert.equal(content.document.body.innerText, text, "Didn't load neterror"); + }); + + // Cleanup. + gBrowser.removeTab(tab); +} + +add_task(async function test_non_blank() { + await runTest("value"); +}); + +add_task(async function test_blank() { + await runTest(""); +}); diff --git a/browser/components/sessionstore/test/browser_restore_tabless_window.js b/browser/components/sessionstore/test/browser_restore_tabless_window.js new file mode 100644 index 0000000000..de2c872f30 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restore_tabless_window.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * It's possible for us to restore windows without tabs, + * when on Windows/Linux the user closes the last tab as + * a means of closing the last window. That last tab + * would appear as a closed tab in session state for the + * window, with no open tabs (so the state would be resumed + * as showing the homepage). See bug 490136 for context. + * This test checks that in this case, the resulting window + * is functional and indeed has the required previously + * closed tabs available. + */ +add_task(async function test_restoring_tabless_window() { + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let newState = { + windows: [ + { + tabs: [], + _closedTabs: [ + { + state: { entries: [{ url: "about:" }] }, + title: "About:", + }, + ], + }, + ], + }; + + await setWindowState(newWin, newState, true); + let tab = await BrowserTestUtils.openNewForegroundTab( + newWin.gBrowser, + "https://example.com" + ); + await TabStateFlusher.flush(tab.linkedBrowser); + let receivedState = SessionStore.getWindowState(newWin); + let { tabs, selected } = receivedState.windows[0]; + is(tabs.length, 2, "Should have two tabs"); + is(selected, 2, "Should have selected the new tab"); + ok( + tabs[1]?.entries.some(e => e.url == "https://example.com/"), + "Should have found the new URL" + ); + + let closedTabData = SessionStore.getClosedTabData(newWin); + is(closedTabData.length, 1, "Should have found 1 closed tab"); + is( + closedTabData[0]?.state.entries[0].url, + "about:", + "Should have found the right URL for the closed tab" + ); + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/sessionstore/test/browser_restored_window_features.js b/browser/components/sessionstore/test/browser_restored_window_features.js new file mode 100644 index 0000000000..6ce22f1bc4 --- /dev/null +++ b/browser/components/sessionstore/test/browser_restored_window_features.js @@ -0,0 +1,164 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const BARPROP_NAMES = [ + "locationbar", + "menubar", + "personalbar", + "scrollbars", + "statusbar", + "toolbar", +]; + +function testFeatures(win, test) { + for (let name of BARPROP_NAMES) { + is( + win[name].visible, + !!test.barprops?.[name], + name + " should be " + (test.barprops?.[name] ? "visible" : "hidden") + ); + } + let toolbar = win.document.getElementById("TabsToolbar"); + is( + toolbar.collapsed, + !win.toolbar.visible, + win.toolbar.visible + ? "tabbar should not be collapsed" + : "tabbar should be collapsed" + ); + let chromeFlags = win.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).chromeFlags; + is(chromeFlags & test.chromeFlags, test.chromeFlags, "flags should be set"); + if (test.unsetFlags) { + is(chromeFlags & test.unsetFlags, 0, "flags should be unset"); + } +} + +add_task(async function testRestoredWindowFeatures() { + const DUMMY_PAGE = "browser/base/content/test/tabs/dummy_page.html"; + const ALL_BARPROPS = { + locationbar: true, + menubar: true, + personalbar: true, + scrollbars: true, + statusbar: true, + toolbar: true, + }; + const TESTS = [ + { + url: "http://example.com/browser/" + DUMMY_PAGE, + features: "menubar=0,resizable", + barprops: { scrollbars: true }, + chromeFlags: Ci.nsIWebBrowserChrome.CHROME_WINDOW_RESIZE, + unsetFlags: Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG, + }, + { + url: "data:,", // title should be empty + checkContentTitleEmpty: true, + features: "location,resizable", + barprops: { locationbar: true, scrollbars: true }, + chromeFlags: Ci.nsIWebBrowserChrome.CHROME_WINDOW_RESIZE, + unsetFlags: Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG, + }, + { + url: "http://example.com/browser/" + DUMMY_PAGE, + features: "dialog,resizable", + barprops: { scrollbars: true }, + chromeFlags: + Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG | + Ci.nsIWebBrowserChrome.CHROME_WINDOW_RESIZE, + }, + { + chrome: true, + url: "http://example.com/browser/" + DUMMY_PAGE, + features: "chrome,all,dialog=no", + barprops: ALL_BARPROPS, + chromeFlags: Ci.nsIWebBrowserChrome.CHROME_ALL, + unsetFlags: Ci.nsIWebBrowserChrome.CHROME_OPENAS_DIALOG, + }, + { + chrome: true, + url: "http://example.com/browser/" + DUMMY_PAGE, + features: "chrome,all,dialog=no,alwayslowered,centerscreen", + barprops: ALL_BARPROPS, + chromeFlags: + Ci.nsIWebBrowserChrome.CHROME_WINDOW_LOWERED | + Ci.nsIWebBrowserChrome.CHROME_CENTER_SCREEN, + }, + { + chrome: true, + url: "http://example.com/browser/" + DUMMY_PAGE, + features: "chrome,all,dialog=no,alwaysraised,dependent", + barprops: ALL_BARPROPS, + chromeFlags: + Ci.nsIWebBrowserChrome.CHROME_WINDOW_RAISED | + Ci.nsIWebBrowserChrome.CHROME_DEPENDENT, + }, + ]; + const TEST_URL_CHROME = "chrome://mochitests/content/browser/" + DUMMY_PAGE; + + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, TEST_URL_CHROME); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + for (let test of TESTS) { + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: test.url, + }); + let win; + if (test.chrome) { + win = window.openDialog( + AppConstants.BROWSER_CHROME_URL, + "_blank", + test.features, + test.url + ); + } else { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [test], t => { + content.window.open(t.url, "_blank", t.features); + }); + } + win = await newWindowPromise; + + let title = win.document.title; + if (test.checkContentTitleEmpty) { + let contentTitle = await SpecialPowers.spawn( + win.gBrowser.selectedBrowser, + [], + () => content.document.title + ); + is(contentTitle, "", "title should be empty"); + } + + testFeatures(win, test); + let chromeFlags = win.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).chromeFlags; + + await BrowserTestUtils.closeWindow(win); + + newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: test.url, + }); + SessionStore.undoCloseWindow(0); + win = await newWindowPromise; + + is(title, win.document.title, "title should be preserved"); + testFeatures(win, test); + is( + win.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow).chromeFlags, + // Use |>>> 0| to force unsigned. + (chromeFlags | + Ci.nsIWebBrowserChrome.CHROME_OPENAS_CHROME | + Ci.nsIWebBrowserChrome.CHROME_SUPPRESS_ANIMATION) >>> + 0, + "unexpected chromeFlags" + ); + + await BrowserTestUtils.closeWindow(win); + } +}); diff --git a/browser/components/sessionstore/test/browser_revive_crashed_bg_tabs.js b/browser/components/sessionstore/test/browser_revive_crashed_bg_tabs.js new file mode 100644 index 0000000000..08225fa6d6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_revive_crashed_bg_tabs.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests that even if the user has set their tabs to restore + * immediately on session start, that background tabs after a + * content process crash restore on demand. + */ + +"use strict"; + +const PAGE_1 = + "data:text/html,<html><body>A%20regular,%20everyday,%20normal%20page."; +const PAGE_2 = + "data:text/html,<html><body>Another%20regular,%20everyday,%20normal%20page."; + +add_setup(async function() { + await pushPrefs( + ["dom.ipc.processCount", 1], + ["browser.sessionstore.restore_on_demand", false] + ); +}); + +add_task(async function test_revive_bg_tabs_on_demand() { + let newTab1 = BrowserTestUtils.addTab(gBrowser, PAGE_1); + let browser1 = newTab1.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser1); + + gBrowser.selectedTab = newTab1; + + let newTab2 = BrowserTestUtils.addTab(gBrowser, PAGE_2); + let browser2 = newTab2.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser2); + + await TabStateFlusher.flush(browser2); + + // Now crash the selected tab + let windowReady = BrowserTestUtils.waitForEvent(window, "SSWindowStateReady"); + await BrowserTestUtils.crashFrame(browser1); + + ok(newTab1.hasAttribute("crashed"), "Selected tab should be crashed"); + ok(!newTab2.hasAttribute("crashed"), "Background tab should not be crashed"); + + // Wait until we've had a chance to restore all tabs immediately + await windowReady; + + // But we should not have restored the background tab + ok(newTab2.hasAttribute("pending"), "Background tab should be pending"); + + // Now select newTab2 to make sure it restores. + let newTab2Restored = promiseTabRestored(newTab2); + gBrowser.selectedTab = newTab2; + await newTab2Restored; + + ok(browser2.isRemoteBrowser, "Restored browser should be remote"); + + BrowserTestUtils.removeTab(newTab1); + BrowserTestUtils.removeTab(newTab2); +}); diff --git a/browser/components/sessionstore/test/browser_scrollPositions.js b/browser/components/sessionstore/test/browser_scrollPositions.js new file mode 100644 index 0000000000..67fa757179 --- /dev/null +++ b/browser/components/sessionstore/test/browser_scrollPositions.js @@ -0,0 +1,258 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const BASE = "http://example.com/browser/browser/components/sessionstore/test/"; +const URL2 = BASE + "browser_scrollPositions_sample2.html"; +const URL_FRAMESET = BASE + "browser_scrollPositions_sample_frameset.html"; + +// Randomized set of scroll positions we will use in this test. +const SCROLL_X = Math.round(100 * (1 + Math.random())); +const SCROLL_Y = Math.round(200 * (1 + Math.random())); +const SCROLL_STR = SCROLL_X + "," + SCROLL_Y; + +const SCROLL2_X = Math.round(300 * (1 + Math.random())); +const SCROLL2_Y = Math.round(400 * (1 + Math.random())); +const SCROLL2_STR = SCROLL2_X + "," + SCROLL2_Y; + +requestLongerTimeout(10); + +add_task(test_scroll_nested); + +if (gFissionBrowser) { + addCoopTask("browser_scrollPositions_sample.html", test_scroll, HTTPSROOT); +} +addNonCoopTask("browser_scrollPositions_sample.html", test_scroll, HTTPROOT); +addNonCoopTask("browser_scrollPositions_sample.html", test_scroll, HTTPSROOT); + +addCoopTask( + "browser_scrollPositions_sample.html", + test_scroll_background_tabs, + HTTPSROOT +); +addNonCoopTask( + "browser_scrollPositions_sample.html", + test_scroll_background_tabs, + HTTPROOT +); + +addNonCoopTask( + "browser_scrollPositions_sample.html", + test_scroll_background_tabs, + HTTPSROOT +); + +function getScrollPosition(bc) { + return SpecialPowers.spawn(bc, [], () => { + let x = {}, + y = {}; + content.windowUtils.getVisualViewportOffset(x, y); + return { x: x.value, y: y.value }; + }); +} + +/** + * This test ensures that we properly serialize and restore scroll positions + * for an average page without any frames. + */ +async function test_scroll(aURL) { + let tab = BrowserTestUtils.addTab(gBrowser, aURL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Scroll down a little. + await setScrollPosition(browser, SCROLL_X, SCROLL_Y); + await checkScroll(tab, { scroll: SCROLL_STR }, "scroll is fine"); + + // Duplicate and check that the scroll position is restored. + let tab2 = ss.duplicateTab(window, tab); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + let scroll = await getScrollPosition(browser2); + is( + JSON.stringify(scroll), + JSON.stringify({ x: SCROLL_X, y: SCROLL_Y }), + "scroll position has been duplicated correctly" + ); + + // Check that reloading retains the scroll positions. + browser2.reload(); + await promiseBrowserLoaded(browser2); + await checkScroll( + tab2, + { scroll: SCROLL_STR }, + "reloading retains scroll positions" + ); + + // Check that a force-reload resets scroll positions. + browser2.reloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE); + await promiseBrowserLoaded(browser2); + await checkScroll(tab2, null, "force-reload resets scroll positions"); + + // Scroll back to the top and check that the position has been reset. We + // expect the scroll position to be "null" here because there is no data to + // be stored if the frame is in its default scroll position. + await setScrollPosition(browser, 0, 0); + await checkScroll(tab, null, "no scroll stored"); + + // Cleanup. + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +} + +/** + * This tests ensures that we properly serialize and restore scroll positions + * for multiple frames of pages with framesets. + */ +async function test_scroll_nested() { + let tab = BrowserTestUtils.addTab(gBrowser, URL_FRAMESET); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Scroll the first child frame down a little. + await setScrollPosition( + browser.browsingContext.children[0], + SCROLL_X, + SCROLL_Y + ); + await checkScroll( + tab, + { children: [{ scroll: SCROLL_STR }] }, + "scroll is fine" + ); + + // Scroll the second child frame down a little. + await setScrollPosition( + browser.browsingContext.children[1], + SCROLL2_X, + SCROLL2_Y + ); + await checkScroll( + tab, + { children: [{ scroll: SCROLL_STR }, { scroll: SCROLL2_STR }] }, + "scroll is fine" + ); + + // Duplicate and check that the scroll position is restored. + let tab2 = ss.duplicateTab(window, tab); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + let scroll = await getScrollPosition(browser2.browsingContext.children[0]); + is( + JSON.stringify(scroll), + JSON.stringify({ x: SCROLL_X, y: SCROLL_Y }), + "scroll position #1 has been duplicated correctly" + ); + + scroll = await getScrollPosition(browser2.browsingContext.children[1]); + is( + JSON.stringify(scroll), + JSON.stringify({ x: SCROLL2_X, y: SCROLL2_Y }), + "scroll position #2 has been duplicated correctly" + ); + + // Check that resetting one frame's scroll position removes it from the + // serialized value. + await setScrollPosition(browser.browsingContext.children[0], 0, 0); + await checkScroll( + tab, + { children: [null, { scroll: SCROLL2_STR }] }, + "scroll is fine" + ); + + // Check the resetting all frames' scroll positions nulls the stored value. + await setScrollPosition(browser.browsingContext.children[1], 0, 0); + await checkScroll(tab, null, "no scroll stored"); + + // Cleanup. + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +} + +/** + * Test that scroll positions persist after restoring background tabs in + * a restored window (bug 1228518). + * Also test that scroll positions for previous session history entries + * are preserved as well (bug 1265818). + */ +async function test_scroll_background_tabs(aURL) { + await pushPrefs(["browser.sessionstore.restore_on_demand", true]); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let tab = BrowserTestUtils.addTab(newWin.gBrowser, aURL); + let browser = tab.linkedBrowser; + await BrowserTestUtils.browserLoaded(browser); + + // Scroll down a little. + await setScrollPosition(browser, SCROLL_X, SCROLL_Y); + await checkScroll( + tab, + { scroll: SCROLL_STR }, + "scroll on first page is fine" + ); + + // Navigate to a different page and scroll there as well. + let browser2loaded = BrowserTestUtils.browserLoaded(browser, false, URL2); + BrowserTestUtils.loadURI(browser, URL2); + await browser2loaded; + + // Scroll down a little. + await setScrollPosition(browser, SCROLL2_X, SCROLL2_Y); + await checkScroll( + tab, + { scroll: SCROLL2_STR }, + "scroll on second page is fine" + ); + + // Close the window + await BrowserTestUtils.closeWindow(newWin); + + await forceSaveState(); + + // Now restore the window + newWin = ss.undoCloseWindow(0); + + // Make sure to wait for the window to be restored. + await BrowserTestUtils.waitForEvent(newWin, "SSWindowStateReady"); + + is(newWin.gBrowser.tabs.length, 2, "There should be two tabs"); + + // The second tab should be the one we loaded aURL at still + tab = newWin.gBrowser.tabs[1]; + + ok(tab.hasAttribute("pending"), "Tab should be pending"); + browser = tab.linkedBrowser; + + // Ensure there are no pending queued messages in the child. + await TabStateFlusher.flush(browser); + + // Now check to see if the background tab remembers where it + // should be scrolled to. + newWin.gBrowser.selectedTab = tab; + await promiseTabRestored(tab); + + await checkScroll( + tab, + { scroll: SCROLL2_STR }, + "scroll is correct for restored tab" + ); + + // Now go back in history and check that the scroll position + // is restored there as well. + is(browser.canGoBack, true, "can go back"); + browser.goBack(); + + await BrowserTestUtils.browserLoaded(browser); + await TabStateFlusher.flush(browser); + + await checkScroll( + tab, + { scroll: SCROLL_STR }, + "scroll is correct after navigating back within the restored tab" + ); + + await BrowserTestUtils.closeWindow(newWin); +} diff --git a/browser/components/sessionstore/test/browser_scrollPositionsReaderMode.js b/browser/components/sessionstore/test/browser_scrollPositionsReaderMode.js new file mode 100644 index 0000000000..c61b37c1a4 --- /dev/null +++ b/browser/components/sessionstore/test/browser_scrollPositionsReaderMode.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const BASE = "http://example.com/browser/browser/components/sessionstore/test/"; +const READER_MODE_URL = + "about:reader?url=" + + encodeURIComponent(BASE + "browser_scrollPositions_readerModeArticle.html"); + +// Randomized set of scroll positions we will use in this test. +const SCROLL_READER_MODE_Y = Math.round(400 * (1 + Math.random())); +const SCROLL_READER_MODE_STR = "0," + SCROLL_READER_MODE_Y; + +requestLongerTimeout(2); + +/** + * Test that scroll positions of about reader page after restoring background + * tabs in a restored window (bug 1153393). + */ +add_task(async function test_scroll_background_about_reader_tabs() { + await pushPrefs(["browser.sessionstore.restore_on_demand", true]); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + let tab = BrowserTestUtils.addTab(newWin.gBrowser, READER_MODE_URL); + let browser = tab.linkedBrowser; + await Promise.all([ + BrowserTestUtils.browserLoaded(browser), + BrowserTestUtils.waitForContentEvent(browser, "AboutReaderContentReady"), + ]); + + // Scroll down a little. + await setScrollPosition(browser, 0, SCROLL_READER_MODE_Y); + await checkScroll(tab, { scroll: SCROLL_READER_MODE_STR }, "scroll is fine"); + + // Close the window + await BrowserTestUtils.closeWindow(newWin); + + await forceSaveState(); + + // Now restore the window + newWin = ss.undoCloseWindow(0); + + // Make sure to wait for the window to be restored. + await BrowserTestUtils.waitForEvent(newWin, "SSWindowStateReady"); + + is(newWin.gBrowser.tabs.length, 2, "There should be two tabs"); + + // The second tab should be the one we loaded URL at still + tab = newWin.gBrowser.tabs[1]; + + ok(tab.hasAttribute("pending"), "Tab should be pending"); + browser = tab.linkedBrowser; + + // Ensure there are no pending queued messages in the child. + await TabStateFlusher.flush(browser); + + // Now check to see if the background tab remembers where it + // should be scrolled to. + newWin.gBrowser.selectedTab = tab; + await Promise.all([ + promiseTabRestored(tab), + BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "AboutReaderContentReady" + ), + ]); + + await checkScroll( + tab, + { scroll: SCROLL_READER_MODE_STR }, + "scroll is still fine" + ); + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/sessionstore/test/browser_scrollPositions_readerModeArticle.html b/browser/components/sessionstore/test/browser_scrollPositions_readerModeArticle.html new file mode 100644 index 0000000000..55452e0439 --- /dev/null +++ b/browser/components/sessionstore/test/browser_scrollPositions_readerModeArticle.html @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html> +<head> +<title>Article title</title> +<meta name="description" content="This is the article description." /> +</head> +<body> +<header>Site header</header> +<div> +<h1>Article title</h1> +<h2 class="author">by Jane Doe</h2> +<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec a diam lectus. Sed sit amet ipsum mauris. Maecenas congue ligula ac quam viverra nec consectetur ante hendrerit. Donec et mollis dolor. Praesent et diam eget libero egestas mattis sit amet vitae augue. Nam tincidunt congue enim, ut porta lorem lacinia consectetur. Donec ut libero sed arcu vehicula ultricies a non tortor. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean ut gravida lorem. Ut turpis felis, pulvinar a semper sed, adipiscing id dolor. Pellentesque auctor nisi id magna consequat sagittis. Curabitur dapibus enim sit amet elit pharetra tincidunt feugiat nisl imperdiet. Ut convallis libero in urna ultrices accumsan. Donec sed odio eros. Donec viverra mi quis quam pulvinar at malesuada arcu rhoncus. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. In rutrum accumsan ultricies. Mauris vitae nisi at sem facilisis semper ac in est.</p> +<p>Vivamus fermentum semper porta. Nunc diam velit, adipiscing ut tristique vitae, sagittis vel odio. Maecenas convallis ullamcorper ultricies. Curabitur ornare, ligula semper consectetur sagittis, nisi diam iaculis velit, id fringilla sem nunc vel mi. Nam dictum, odio nec pretium volutpat, arcu ante placerat erat, non tristique elit urna et turpis. Quisque mi metus, ornare sit amet fermentum et, tincidunt et orci. Fusce eget orci a orci congue vestibulum. Ut dolor diam, elementum et vestibulum eu, porttitor vel elit. Curabitur venenatis pulvinar tellus gravida ornare. Sed et erat faucibus nunc euismod ultricies ut id justo. Nullam cursus suscipit nisi, et ultrices justo sodales nec. Fusce venenatis facilisis lectus ac semper. Aliquam at massa ipsum. Quisque bibendum purus convallis nulla ultrices ultricies. Nullam aliquam, mi eu aliquam tincidunt, purus velit laoreet tortor, viverra pretium nisi quam vitae mi. Fusce vel volutpat elit. Nam sagittis nisi dui.</p> +<p>Vivamus fermentum semper porta. Nunc diam velit, adipiscing ut tristique vitae, sagittis vel odio. Maecenas convallis ullamcorper ultricies. Curabitur ornare, ligula semper consectetur sagittis, nisi diam iaculis velit, id fringilla sem nunc vel mi. Nam dictum, odio nec pretium volutpat, arcu ante placerat erat, non tristique elit urna et turpis. Quisque mi metus, ornare sit amet fermentum et, tincidunt et orci. Fusce eget orci a orci congue vestibulum. Ut dolor diam, elementum et vestibulum eu, porttitor vel elit. Curabitur venenatis pulvinar tellus gravida ornare. Sed et erat faucibus nunc euismod ultricies ut id justo. Nullam cursus suscipit nisi, et ultrices justo sodales nec. Fusce venenatis facilisis lectus ac semper. Aliquam at massa ipsum. Quisque bibendum purus convallis nulla ultrices ultricies. Nullam aliquam, mi eu aliquam tincidunt, purus velit laoreet tortor, viverra pretium nisi quam vitae mi. Fusce vel volutpat elit. Nam sagittis nisi dui.</p> +<p>Vivamus fermentum semper porta. Nunc diam velit, adipiscing ut tristique vitae, sagittis vel odio. Maecenas convallis ullamcorper ultricies. Curabitur ornare, ligula semper consectetur sagittis, nisi diam iaculis velit, id fringilla sem nunc vel mi. Nam dictum, odio nec pretium volutpat, arcu ante placerat erat, non tristique elit urna et turpis. Quisque mi metus, ornare sit amet fermentum et, tincidunt et orci. Fusce eget orci a orci congue vestibulum. Ut dolor diam, elementum et vestibulum eu, porttitor vel elit. Curabitur venenatis pulvinar tellus gravida ornare. Sed et erat faucibus nunc euismod ultricies ut id justo. Nullam cursus suscipit nisi, et ultrices justo sodales nec. Fusce venenatis facilisis lectus ac semper. Aliquam at massa ipsum. Quisque bibendum purus convallis nulla ultrices ultricies. Nullam aliquam, mi eu aliquam tincidunt, purus velit laoreet tortor, viverra pretium nisi quam vitae mi. Fusce vel volutpat elit. Nam sagittis nisi dui.</p> +<p>Vivamus fermentum semper porta. Nunc diam velit, adipiscing ut tristique vitae, sagittis vel odio. Maecenas convallis ullamcorper ultricies. Curabitur ornare, ligula semper consectetur sagittis, nisi diam iaculis velit, id fringilla sem nunc vel mi. Nam dictum, odio nec pretium volutpat, arcu ante placerat erat, non tristique elit urna et turpis. Quisque mi metus, ornare sit amet fermentum et, tincidunt et orci. Fusce eget orci a orci congue vestibulum. Ut dolor diam, elementum et vestibulum eu, porttitor vel elit. Curabitur venenatis pulvinar tellus gravida ornare. Sed et erat faucibus nunc euismod ultricies ut id justo. Nullam cursus suscipit nisi, et ultrices justo sodales nec. Fusce venenatis facilisis lectus ac semper. Aliquam at massa ipsum. Quisque bibendum purus convallis nulla ultrices ultricies. Nullam aliquam, mi eu aliquam tincidunt, purus velit laoreet tortor, viverra pretium nisi quam vitae mi. Fusce vel volutpat elit. Nam sagittis nisi dui.</p> +<p>Vivamus fermentum semper porta. Nunc diam velit, adipiscing ut tristique vitae, sagittis vel odio. Maecenas convallis ullamcorper ultricies. Curabitur ornare, ligula semper consectetur sagittis, nisi diam iaculis velit, id fringilla sem nunc vel mi. Nam dictum, odio nec pretium volutpat, arcu ante placerat erat, non tristique elit urna et turpis. Quisque mi metus, ornare sit amet fermentum et, tincidunt et orci. Fusce eget orci a orci congue vestibulum. Ut dolor diam, elementum et vestibulum eu, porttitor vel elit. Curabitur venenatis pulvinar tellus gravida ornare. Sed et erat faucibus nunc euismod ultricies ut id justo. Nullam cursus suscipit nisi, et ultrices justo sodales nec. Fusce venenatis facilisis lectus ac semper. Aliquam at massa ipsum. Quisque bibendum purus convallis nulla ultrices ultricies. Nullam aliquam, mi eu aliquam tincidunt, purus velit laoreet tortor, viverra pretium nisi quam vitae mi. Fusce vel volutpat elit. Nam sagittis nisi dui.</p> +<p>Vivamus fermentum semper porta. Nunc diam velit, adipiscing ut tristique vitae, sagittis vel odio. Maecenas convallis ullamcorper ultricies. Curabitur ornare, ligula semper consectetur sagittis, nisi diam iaculis velit, id fringilla sem nunc vel mi. Nam dictum, odio nec pretium volutpat, arcu ante placerat erat, non tristique elit urna et turpis. Quisque mi metus, ornare sit amet fermentum et, tincidunt et orci. Fusce eget orci a orci congue vestibulum. Ut dolor diam, elementum et vestibulum eu, porttitor vel elit. Curabitur venenatis pulvinar tellus gravida ornare. Sed et erat faucibus nunc euismod ultricies ut id justo. Nullam cursus suscipit nisi, et ultrices justo sodales nec. Fusce venenatis facilisis lectus ac semper. Aliquam at massa ipsum. Quisque bibendum purus convallis nulla ultrices ultricies. Nullam aliquam, mi eu aliquam tincidunt, purus velit laoreet tortor, viverra pretium nisi quam vitae mi. Fusce vel volutpat elit. Nam sagittis nisi dui.</p> +<p>Vivamus fermentum semper porta. Nunc diam velit, adipiscing ut tristique vitae, sagittis vel odio. Maecenas convallis ullamcorper ultricies. Curabitur ornare, ligula semper consectetur sagittis, nisi diam iaculis velit, id fringilla sem nunc vel mi. Nam dictum, odio nec pretium volutpat, arcu ante placerat erat, non tristique elit urna et turpis. Quisque mi metus, ornare sit amet fermentum et, tincidunt et orci. Fusce eget orci a orci congue vestibulum. Ut dolor diam, elementum et vestibulum eu, porttitor vel elit. Curabitur venenatis pulvinar tellus gravida ornare. Sed et erat faucibus nunc euismod ultricies ut id justo. Nullam cursus suscipit nisi, et ultrices justo sodales nec. Fusce venenatis facilisis lectus ac semper. Aliquam at massa ipsum. Quisque bibendum purus convallis nulla ultrices ultricies. Nullam aliquam, mi eu aliquam tincidunt, purus velit laoreet tortor, viverra pretium nisi quam vitae mi. Fusce vel volutpat elit. Nam sagittis nisi dui.</p> +<p>Vivamus fermentum semper porta. Nunc diam velit, adipiscing ut tristique vitae, sagittis vel odio. Maecenas convallis ullamcorper ultricies. Curabitur ornare, ligula semper consectetur sagittis, nisi diam iaculis velit, id fringilla sem nunc vel mi. Nam dictum, odio nec pretium volutpat, arcu ante placerat erat, non tristique elit urna et turpis. Quisque mi metus, ornare sit amet fermentum et, tincidunt et orci. Fusce eget orci a orci congue vestibulum. Ut dolor diam, elementum et vestibulum eu, porttitor vel elit. Curabitur venenatis pulvinar tellus gravida ornare. Sed et erat faucibus nunc euismod ultricies ut id justo. Nullam cursus suscipit nisi, et ultrices justo sodales nec. Fusce venenatis facilisis lectus ac semper. Aliquam at massa ipsum. Quisque bibendum purus convallis nulla ultrices ultricies. Nullam aliquam, mi eu aliquam tincidunt, purus velit laoreet tortor, viverra pretium nisi quam vitae mi. Fusce vel volutpat elit. Nam sagittis nisi dui.</p> +<p>Vivamus fermentum semper porta. Nunc diam velit, adipiscing ut tristique vitae, sagittis vel odio. Maecenas convallis ullamcorper ultricies. Curabitur ornare, ligula semper consectetur sagittis, nisi diam iaculis velit, id fringilla sem nunc vel mi. Nam dictum, odio nec pretium volutpat, arcu ante placerat erat, non tristique elit urna et turpis. Quisque mi metus, ornare sit amet fermentum et, tincidunt et orci. Fusce eget orci a orci congue vestibulum. Ut dolor diam, elementum et vestibulum eu, porttitor vel elit. Curabitur venenatis pulvinar tellus gravida ornare. Sed et erat faucibus nunc euismod ultricies ut id justo. Nullam cursus suscipit nisi, et ultrices justo sodales nec. Fusce venenatis facilisis lectus ac semper. Aliquam at massa ipsum. Quisque bibendum purus convallis nulla ultrices ultricies. Nullam aliquam, mi eu aliquam tincidunt, purus velit laoreet tortor, viverra pretium nisi quam vitae mi. Fusce vel volutpat elit. Nam sagittis nisi dui.</p> +<p>Vivamus fermentum semper porta. Nunc diam velit, adipiscing ut tristique vitae, sagittis vel odio. Maecenas convallis ullamcorper ultricies. Curabitur ornare, ligula semper consectetur sagittis, nisi diam iaculis velit, id fringilla sem nunc vel mi. Nam dictum, odio nec pretium volutpat, arcu ante placerat erat, non tristique elit urna et turpis. Quisque mi metus, ornare sit amet fermentum et, tincidunt et orci. Fusce eget orci a orci congue vestibulum. Ut dolor diam, elementum et vestibulum eu, porttitor vel elit. Curabitur venenatis pulvinar tellus gravida ornare. Sed et erat faucibus nunc euismod ultricies ut id justo. Nullam cursus suscipit nisi, et ultrices justo sodales nec. Fusce venenatis facilisis lectus ac semper. Aliquam at massa ipsum. Quisque bibendum purus convallis nulla ultrices ultricies. Nullam aliquam, mi eu aliquam tincidunt, purus velit laoreet tortor, viverra pretium nisi quam vitae mi. Fusce vel volutpat elit. Nam sagittis nisi dui.</p> +<p>Vivamus fermentum semper porta. Nunc diam velit, adipiscing ut tristique vitae, sagittis vel odio. Maecenas convallis ullamcorper ultricies. Curabitur ornare, ligula semper consectetur sagittis, nisi diam iaculis velit, id fringilla sem nunc vel mi. Nam dictum, odio nec pretium volutpat, arcu ante placerat erat, non tristique elit urna et turpis. Quisque mi metus, ornare sit amet fermentum et, tincidunt et orci. Fusce eget orci a orci congue vestibulum. Ut dolor diam, elementum et vestibulum eu, porttitor vel elit. Curabitur venenatis pulvinar tellus gravida ornare. Sed et erat faucibus nunc euismod ultricies ut id justo. Nullam cursus suscipit nisi, et ultrices justo sodales nec. Fusce venenatis facilisis lectus ac semper. Aliquam at massa ipsum. Quisque bibendum purus convallis nulla ultrices ultricies. Nullam aliquam, mi eu aliquam tincidunt, purus velit laoreet tortor, viverra pretium nisi quam vitae mi. Fusce vel volutpat elit. Nam sagittis nisi dui.</p> +</div> +</body> +</html> diff --git a/browser/components/sessionstore/test/browser_scrollPositions_sample.html b/browser/components/sessionstore/test/browser_scrollPositions_sample.html new file mode 100644 index 0000000000..0182783dbb --- /dev/null +++ b/browser/components/sessionstore/test/browser_scrollPositions_sample.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>browser_scrollPositions_sample.html</title> + </head> + <body style='width: 100000px; height: 100000px;'>top</body> +</html> diff --git a/browser/components/sessionstore/test/browser_scrollPositions_sample2.html b/browser/components/sessionstore/test/browser_scrollPositions_sample2.html new file mode 100644 index 0000000000..0182783dbb --- /dev/null +++ b/browser/components/sessionstore/test/browser_scrollPositions_sample2.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>browser_scrollPositions_sample.html</title> + </head> + <body style='width: 100000px; height: 100000px;'>top</body> +</html> diff --git a/browser/components/sessionstore/test/browser_scrollPositions_sample_frameset.html b/browser/components/sessionstore/test/browser_scrollPositions_sample_frameset.html new file mode 100644 index 0000000000..c7e363fa1d --- /dev/null +++ b/browser/components/sessionstore/test/browser_scrollPositions_sample_frameset.html @@ -0,0 +1,11 @@ +<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Frameset//EN"> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>browser_scrollPositions_sample_frameset.html</title> + </head> + <frameset id="frames" rows="50%, 50%"> + <frame src="browser_scrollPositions_sample.html"> + <frame src="browser_scrollPositions_sample.html"> + </frameset> +</html> diff --git a/browser/components/sessionstore/test/browser_send_async_message_oom.js b/browser/components/sessionstore/test/browser_send_async_message_oom.js new file mode 100644 index 0000000000..18f6e4df8a --- /dev/null +++ b/browser/components/sessionstore/test/browser_send_async_message_oom.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +const HISTOGRAM_NAME = "FX_SESSION_RESTORE_SEND_UPDATE_CAUSED_OOM"; + +/** + * Test that an OOM in sendAsyncMessage in a framescript will be reported + * to Telemetry. + */ + +add_setup(async function() { + Services.telemetry.canRecordExtended = true; +}); + +function frameScript() { + // Make send[A]syncMessage("SessionStore:update", ...) simulate OOM. + // Other operations are unaffected. + let mm = docShell.messageManager; + + let wrap = function(original) { + return function(name, ...args) { + if (name != "SessionStore:update") { + return original(name, ...args); + } + throw new Components.Exception( + "Simulated OOM", + Cr.NS_ERROR_OUT_OF_MEMORY + ); + }; + }; + + mm.sendAsyncMessage = wrap(mm.sendAsyncMessage.bind(mm)); + mm.sendSyncMessage = wrap(mm.sendSyncMessage.bind(mm)); +} + +add_task(async function() { + // Capture original state. + let snapshot = Services.telemetry.getHistogramById(HISTOGRAM_NAME).snapshot(); + + // Open a browser, configure it to cause OOM. + let newTab = BrowserTestUtils.addTab(gBrowser, "about:robots"); + let browser = newTab.linkedBrowser; + await ContentTask.spawn(browser, null, frameScript); + + let promiseReported = new Promise(resolve => { + browser.messageManager.addMessageListener("SessionStore:error", resolve); + }); + + // Attempt to flush. This should fail. + let promiseFlushed = TabStateFlusher.flush(browser); + promiseFlushed.then(success => { + if (success) { + throw new Error("Flush should have failed"); + } + }); + + // The frame script should report an error. + await promiseReported; + + // Give us some time to handle that error. + await new Promise(resolve => setTimeout(resolve, 10)); + + // By now, Telemetry should have been updated. + let snapshot2 = Services.telemetry + .getHistogramById(HISTOGRAM_NAME) + .snapshot(); + gBrowser.removeTab(newTab); + + Assert.ok(snapshot2.sum > snapshot.sum); +}); + +add_task(async function cleanup() { + Services.telemetry.canRecordExtended = false; +}); diff --git a/browser/components/sessionstore/test/browser_sessionHistory.js b/browser/components/sessionstore/test/browser_sessionHistory.js new file mode 100644 index 0000000000..def982e226 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionHistory.js @@ -0,0 +1,331 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +/** + * Ensure that starting a load invalidates shistory. + */ +add_task(async function test_load_start() { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + const PAGE = "http://example.com/"; + + // Load a new URI. + let historyReplacePromise = promiseOnHistoryReplaceEntry(browser); + BrowserTestUtils.loadURI(browser, PAGE); + + // Remove the tab before it has finished loading. + await historyReplacePromise; + await promiseRemoveTabAndSessionState(tab); + + // Undo close the tab. + tab = ss.undoCloseTab(window, 0); + browser = tab.linkedBrowser; + await promiseTabRestored(tab); + + // Check that the correct URL was restored. + is(browser.currentURI.spec, PAGE, "url is correct"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that anchor navigation invalidates shistory. + */ +add_task(async function test_hashchange() { + const PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content/", + "http://example.com/" + ); + const URL = PATH + "file_sessionHistory_hashchange.html"; + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Check that we start with a single shistory entry. + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is one shistory entry"); + + // Click the link and wait for a hashchange event. + let eventPromise = BrowserTestUtils.waitForContentEvent( + browser, + "hashchange", + true + ); + await SpecialPowers.spawn(browser, [], async function() { + content.document.querySelector("#a").click(); + }); + info("About to watch for a hash change event"); + await eventPromise; + info("Got a hash change event"); + + // Check that we now have two shistory entries. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there are two shistory entries"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that loading pages from the bfcache invalidates shistory. + */ +add_task(async function test_pageshow() { + const URL = "data:text/html;charset=utf-8,<h1>first</h1>"; + const URL2 = "data:text/html;charset=utf-8,<h1>second</h1>"; + + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Create a second shistory entry. + BrowserTestUtils.loadURI(browser, URL2); + await promiseBrowserLoaded(browser); + + // Wait until shistory changes. + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + browser, + "pageshow", + true + ); + + // Go back to the previous url which is loaded from the bfcache. + browser.goBack(); + await pageShowPromise; + is(browser.currentURI.spec, URL, "correct url after going back"); + + // Check that loading from bfcache did invalidate shistory. + await TabStateFlusher.flush(browser); + let { index } = JSON.parse(ss.getTabState(tab)); + is(index, 1, "first history entry is selected"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that subframe navigation invalidates shistory. + */ +add_task(async function test_subframes() { + const URL = + "data:text/html;charset=utf-8," + + "<iframe src=http%3A//example.com/ name=t></iframe>" + + "<a id=a1 href=http%3A//example.com/1 target=t>clickme</a>" + + "<a id=a2 href=http%3A//example.com/%23 target=t>clickme</a>"; + + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Check that we have a single shistory entry. + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is one shistory entry"); + is(entries[0].children.length, 1, "the entry has one child"); + + // Navigate the subframe. + await SpecialPowers.spawn(browser, [], async function() { + content.document.querySelector("#a1").click(); + }); + await promiseBrowserLoaded( + browser, + false /* wait for subframe load only */, + "http://example.com/1" + ); + + // Check shistory. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there now are two shistory entries"); + is(entries[1].children.length, 1, "the second entry has one child"); + + // Go back in history. + let goneBack = promiseBrowserLoaded( + browser, + false /* wait for subframe load only */, + "http://example.com/" + ); + info("About to go back in history"); + browser.goBack(); + await goneBack; + + // Navigate the subframe again. + let eventPromise = BrowserTestUtils.waitForContentEvent( + browser, + "hashchange", + true + ); + await SpecialPowers.spawn(browser, [], async function() { + content.document.querySelector("#a2").click(); + }); + await eventPromise; + + // Check shistory. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there now are two shistory entries"); + is(entries[1].children.length, 1, "the second entry has one child"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that navigating from an about page invalidates shistory. + */ +add_task(async function test_about_page_navigate() { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Check that we have a single shistory entry. + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is one shistory entry"); + is(entries[0].url, "about:blank", "url is correct"); + + // Verify that the title is also recorded. + is(entries[0].title, "about:blank", "title is correct"); + + BrowserTestUtils.loadURI(browser, "about:robots"); + await promiseBrowserLoaded(browser); + + // Check that we have changed the history entry. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 1, "there is one shistory entry"); + is(entries[0].url, "about:robots", "url is correct"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that history.pushState and history.replaceState invalidate shistory. + */ +add_task(async function test_pushstate_replacestate() { + // Create a new tab. + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/1"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Check that we have a single shistory entry. + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is one shistory entry"); + is(entries[0].url, "http://example.com/1", "url is correct"); + + await SpecialPowers.spawn(browser, [], async function() { + content.window.history.pushState({}, "", "test-entry/"); + }); + + // Check that we have added the history entry. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there is another shistory entry"); + is(entries[1].url, "http://example.com/test-entry/", "url is correct"); + + await SpecialPowers.spawn(browser, [], async function() { + content.window.history.replaceState({}, "", "test-entry2/"); + }); + + // Check that we have modified the history entry. + await TabStateFlusher.flush(browser); + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 2, "there is still two shistory entries"); + is( + entries[1].url, + "http://example.com/test-entry/test-entry2/", + "url is correct" + ); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that slow loading subframes will invalidate shistory. + */ +add_task(async function test_slow_subframe_load() { + const SLOW_URL = + "http://mochi.test:8888/browser/browser/components/" + + "sessionstore/test/browser_sessionHistory_slow.sjs"; + + const URL = + "data:text/html;charset=utf-8," + + "<frameset cols=50%25,50%25>" + + "<frame src='" + + SLOW_URL + + "'>" + + "</frameset>"; + + // Add a new tab with a slow loading subframe + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + + // Check the number of children. + is(entries.length, 1, "there is one root entry ..."); + is(entries[0].children.length, 1, "... with one child entries"); + + // Check URLs. + ok(entries[0].url.startsWith("data:text/html"), "correct root url"); + is(entries[0].children[0].url, SLOW_URL, "correct url for subframe"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + +/** + * Ensure that document wireframes can be persisted when they're enabled. + */ +add_task(async function test_wireframes() { + // Wireframes only works when Fission and SHIP are enabled. + if ( + !Services.appinfo.fissionAutostart || + !Services.appinfo.sessionHistoryInParent + ) { + ok(true, "Skipping test_wireframes when Fission or SHIP is not enabled."); + return; + } + + await SpecialPowers.pushPrefEnv({ + set: [["browser.history.collectWireframes", true]], + }); + + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + await TabStateFlusher.flush(browser); + let { entries } = JSON.parse(ss.getTabState(tab)); + + // Check the number of children. + is(entries.length, 1, "there is one shistory entry"); + + // Check for the wireframe + ok(entries[0].wireframe, "A wireframe was captured and serialized."); + ok( + entries[0].wireframe.rects.length, + "Several wireframe rects were captured." + ); + + // Cleanup. + gBrowser.removeTab(tab); +}); diff --git a/browser/components/sessionstore/test/browser_sessionHistory_slow.sjs b/browser/components/sessionstore/test/browser_sessionHistory_slow.sjs new file mode 100644 index 0000000000..abb1dee829 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionHistory_slow.sjs @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const DELAY_MS = "2000"; + +let timer; + +function handleRequest(req, resp) { + resp.processAsync(); + resp.setHeader("Cache-Control", "no-cache", false); + resp.setHeader("Content-Type", "text/html;charset=utf-8", false); + + timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + resp.write("hi"); + resp.finish(); + }, + DELAY_MS, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/browser/components/sessionstore/test/browser_sessionStorage.html b/browser/components/sessionstore/test/browser_sessionStorage.html new file mode 100644 index 0000000000..f3664ebc9b --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStorage.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>browser_sessionStorage.html</title> + </head> + <body> + <iframe id="iframe"></iframe> + + <script type="text/javascript"> + let isOuter = window == window.top; + let args = window.location.search.slice(1).split("&"); + let rand = args[0]; + + if (isOuter) { + let iframe = document.getElementById("iframe"); + let isSecure = args.indexOf("secure") > -1; + let scheme = isSecure ? "https" : "http"; + iframe.setAttribute("src", scheme + "://example.com" + location.pathname + "?" + rand); + } + + if (sessionStorage.length === 0) { + sessionStorage.test = (isOuter ? "outer" : "inner") + "-value-" + rand; + document.title = sessionStorage.test; + } + </script> + </body> +</html> diff --git a/browser/components/sessionstore/test/browser_sessionStorage.js b/browser/components/sessionstore/test/browser_sessionStorage.js new file mode 100644 index 0000000000..1f3ff8abff --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStorage.js @@ -0,0 +1,300 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const RAND = Math.random(); +const URL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_sessionStorage.html" + + "?" + + RAND; + +const HAS_FIRST_PARTY_DOMAIN = [ + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, +].includes(Services.prefs.getIntPref("network.cookie.cookieBehavior")); +const OUTER_ORIGIN = "http://mochi.test:8888"; +const FIRST_PARTY_DOMAIN = escape("(http,mochi.test)"); +const INNER_ORIGIN = HAS_FIRST_PARTY_DOMAIN + ? `http://example.com^partitionKey=${FIRST_PARTY_DOMAIN}` + : "http://example.com"; +const SECURE_INNER_ORIGIN = HAS_FIRST_PARTY_DOMAIN + ? `https://example.com^partitionKey=${FIRST_PARTY_DOMAIN}` + : "https://example.com"; + +const OUTER_VALUE = "outer-value-" + RAND; +const INNER_VALUE = "inner-value-" + RAND; + +/** + * This test ensures that setting, modifying and restoring sessionStorage data + * works as expected. + */ +add_task(async function session_storage() { + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser); + + let { storage } = JSON.parse(ss.getTabState(tab)); + is( + storage[INNER_ORIGIN].test, + INNER_VALUE, + "sessionStorage data for example.com has been serialized correctly" + ); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "sessionStorage data for mochi.test has been serialized correctly" + ); + + // Ensure that modifying sessionStore values works for the inner frame only. + await modifySessionStorage(browser, { test: "modified1" }, { frameIndex: 0 }); + await TabStateFlusher.flush(browser); + + ({ storage } = JSON.parse(ss.getTabState(tab))); + is( + storage[INNER_ORIGIN].test, + "modified1", + "sessionStorage data for example.com has been serialized correctly" + ); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "sessionStorage data for mochi.test has been serialized correctly" + ); + + // Ensure that modifying sessionStore values works for both frames. + await modifySessionStorage(browser, { test: "modified" }); + await modifySessionStorage(browser, { test: "modified2" }, { frameIndex: 0 }); + await TabStateFlusher.flush(browser); + + ({ storage } = JSON.parse(ss.getTabState(tab))); + is( + storage[INNER_ORIGIN].test, + "modified2", + "sessionStorage data for example.com has been serialized correctly" + ); + is( + storage[OUTER_ORIGIN].test, + "modified", + "sessionStorage data for mochi.test has been serialized correctly" + ); + + // Test that duplicating a tab works. + let tab2 = gBrowser.duplicateTab(tab); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser2); + + ({ storage } = JSON.parse(ss.getTabState(tab2))); + is( + storage[INNER_ORIGIN].test, + "modified2", + "sessionStorage data for example.com has been duplicated correctly" + ); + is( + storage[OUTER_ORIGIN].test, + "modified", + "sessionStorage data for mochi.test has been duplicated correctly" + ); + + // Ensure that the content script retains restored data + // (by e.g. duplicateTab) and sends it along with new data. + await modifySessionStorage(browser2, { test: "modified3" }); + await TabStateFlusher.flush(browser2); + + ({ storage } = JSON.parse(ss.getTabState(tab2))); + is( + storage[INNER_ORIGIN].test, + "modified2", + "sessionStorage data for example.com has been duplicated correctly" + ); + is( + storage[OUTER_ORIGIN].test, + "modified3", + "sessionStorage data for mochi.test has been duplicated correctly" + ); + + // Check that loading a new URL discards data. + BrowserTestUtils.loadURI(browser2, "http://mochi.test:8888/"); + await promiseBrowserLoaded(browser2); + await TabStateFlusher.flush(browser2); + + ({ storage } = JSON.parse(ss.getTabState(tab2))); + is( + storage[OUTER_ORIGIN].test, + "modified3", + "navigating retains correct storage data" + ); + + is( + storage[INNER_ORIGIN].test, + "modified2", + "sessionStorage data for example.com wasn't discarded after top-level same-site navigation" + ); + + // Test that clearing the data in the first tab works properly within + // the subframe + await modifySessionStorage(browser, {}, { frameIndex: 0 }); + await TabStateFlusher.flush(browser); + ({ storage } = JSON.parse(ss.getTabState(tab))); + is( + storage[INNER_ORIGIN], + undefined, + "sessionStorage data for example.com has been cleared correctly" + ); + + // Test that clearing the data in the first tab works properly within + // the top-level frame + await modifySessionStorage(browser, {}); + await TabStateFlusher.flush(browser); + ({ storage } = JSON.parse(ss.getTabState(tab))); + ok( + storage === null || storage === undefined, + "sessionStorage data for the entire tab has been cleared correctly" + ); + + // Clean up. + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +/** + * This test ensures that purging domain data also purges data from the + * sessionStorage data collected for tabs. + */ +add_task(async function purge_domain() { + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Purge data for "mochi.test". + await purgeDomainData(browser, "mochi.test"); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser); + + let { storage } = JSON.parse(ss.getTabState(tab)); + ok( + !storage[OUTER_ORIGIN], + "sessionStorage data for mochi.test has been purged" + ); + is( + storage[INNER_ORIGIN].test, + INNER_VALUE, + "sessionStorage data for example.com has been preserved" + ); + + BrowserTestUtils.removeTab(tab); +}); + +/** + * This test ensures that collecting sessionStorage data respects the privacy + * levels as set by the user. + */ +add_task(async function respect_privacy_level() { + let tab = BrowserTestUtils.addTab(gBrowser, URL + "&secure"); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + await promiseRemoveTabAndSessionState(tab); + + let [ + { + state: { storage }, + }, + ] = ss.getClosedTabData(window); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "http sessionStorage data has been saved" + ); + is( + storage[SECURE_INNER_ORIGIN].test, + INNER_VALUE, + "https sessionStorage data has been saved" + ); + + // Disable saving data for encrypted sites. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 1); + + tab = BrowserTestUtils.addTab(gBrowser, URL + "&secure"); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + await promiseRemoveTabAndSessionState(tab); + + [ + { + state: { storage }, + }, + ] = ss.getClosedTabData(window); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "http sessionStorage data has been saved" + ); + ok( + !storage[SECURE_INNER_ORIGIN], + "https sessionStorage data has *not* been saved" + ); + + // Disable saving data for any site. + Services.prefs.setIntPref("browser.sessionstore.privacy_level", 2); + + // Check that duplicating a tab copies all private data. + tab = BrowserTestUtils.addTab(gBrowser, URL + "&secure"); + await promiseBrowserLoaded(tab.linkedBrowser); + let tab2 = gBrowser.duplicateTab(tab); + await promiseTabRestored(tab2); + await promiseRemoveTabAndSessionState(tab); + + // With privacy_level=2 the |tab| shouldn't have any sessionStorage data. + [ + { + state: { storage }, + }, + ] = ss.getClosedTabData(window); + ok(!storage, "sessionStorage data has *not* been saved"); + + // Remove all closed tabs before continuing with the next test. + // As Date.now() isn't monotonic we might sometimes check + // the wrong closedTabData entry. + while (ss.getClosedTabCount(window) > 0) { + ss.forgetClosedTab(window, 0); + } + + // Restore the default privacy level and close the duplicated tab. + Services.prefs.clearUserPref("browser.sessionstore.privacy_level"); + await promiseRemoveTabAndSessionState(tab2); + + // With privacy_level=0 the duplicated |tab2| should persist all data. + [ + { + state: { storage }, + }, + ] = ss.getClosedTabData(window); + is( + storage[OUTER_ORIGIN].test, + OUTER_VALUE, + "http sessionStorage data has been saved" + ); + is( + storage[SECURE_INNER_ORIGIN].test, + INNER_VALUE, + "https sessionStorage data has been saved" + ); +}); + +function purgeDomainData(browser, domain) { + return new Promise(resolve => { + Services.clearData.deleteDataFromHost( + domain, + true, + Services.clearData.CLEAR_SESSION_HISTORY, + resolve + ); + }); +} diff --git a/browser/components/sessionstore/test/browser_sessionStorage_size.js b/browser/components/sessionstore/test/browser_sessionStorage_size.js new file mode 100644 index 0000000000..1045482817 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStorage_size.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const RAND = Math.random(); +const URL = + "http://mochi.test:8888/browser/" + + "browser/components/sessionstore/test/browser_sessionStorage.html" + + "?" + + RAND; + +const OUTER_VALUE = "outer-value-" + RAND; + +// Lower the size limit for DOM Storage content. Check that DOM Storage +// is not updated, but that other things remain updated. +add_task(async function test_large_content() { + Services.prefs.setIntPref("browser.sessionstore.dom_storage_limit", 5); + + let tab = BrowserTestUtils.addTab(gBrowser, URL); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to make sure chrome received all data. + await TabStateFlusher.flush(browser); + + let state = JSON.parse(ss.getTabState(tab)); + info(JSON.stringify(state, null, "\t")); + Assert.equal(state.storage, null, "We have no storage for the tab"); + Assert.equal(state.entries[0].title, OUTER_VALUE); + BrowserTestUtils.removeTab(tab); + + Services.prefs.clearUserPref("browser.sessionstore.dom_storage_limit"); +}); diff --git a/browser/components/sessionstore/test/browser_sessionStoreContainer.js b/browser/components/sessionstore/test/browser_sessionStoreContainer.js new file mode 100644 index 0000000000..ab92a6ba75 --- /dev/null +++ b/browser/components/sessionstore/test/browser_sessionStoreContainer.js @@ -0,0 +1,161 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +add_task(async function() { + for (let i = 0; i < 3; ++i) { + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/", { + userContextId: i, + }); + let browser = tab.linkedBrowser; + + await promiseBrowserLoaded(browser); + + let tab2 = gBrowser.duplicateTab(tab); + Assert.equal(tab2.getAttribute("usercontextid"), i); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + await SpecialPowers.spawn(browser2, [{ expectedId: i }], async function( + args + ) { + let loadContext = docShell.QueryInterface(Ci.nsILoadContext); + Assert.equal( + loadContext.originAttributes.userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + }); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); + } +}); + +add_task(async function() { + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/", { + userContextId: 1, + }); + let browser = tab.linkedBrowser; + + await promiseBrowserLoaded(browser); + + gBrowser.selectedTab = tab; + + let tab2 = gBrowser.duplicateTab(tab); + let browser2 = tab2.linkedBrowser; + await promiseTabRestored(tab2); + + await SpecialPowers.spawn(browser2, [{ expectedId: 1 }], async function( + args + ) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + }); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function() { + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/", { + userContextId: 1, + }); + let browser = tab.linkedBrowser; + + await promiseBrowserLoaded(browser); + + gBrowser.removeTab(tab); + + let tab2 = ss.undoCloseTab(window, 0); + Assert.equal(tab2.getAttribute("usercontextid"), 1); + await promiseTabRestored(tab2); + await SpecialPowers.spawn( + tab2.linkedBrowser, + [{ expectedId: 1 }], + async function(args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + } + ); + + BrowserTestUtils.removeTab(tab2); +}); + +// Opens "uri" in a new tab with the provided userContextId and focuses it. +// Returns the newly opened tab. +async function openTabInUserContext(userContextId) { + // Open the tab in the correct userContextId. + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com", { + userContextId, + }); + + // Select tab and make sure its browser is focused. + gBrowser.selectedTab = tab; + tab.ownerGlobal.focus(); + + let browser = gBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser); + return { tab, browser }; +} + +function waitForNewCookie() { + return new Promise(resolve => { + Services.obs.addObserver(function observer(subj, topic, data) { + if (data == "added") { + Services.obs.removeObserver(observer, topic); + resolve(); + } + }, "session-cookie-changed"); + }); +} + +add_task(async function test() { + const USER_CONTEXTS = ["default", "personal", "work"]; + + // Make sure userContext is enabled. + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + Services.cookies.removeAll(); + + for (let userContextId of Object.keys(USER_CONTEXTS)) { + // Load the page in 3 different contexts and set a cookie + // which should only be visible in that context. + let cookie = USER_CONTEXTS[userContextId]; + + // Open our tab in the given user context. + let { tab, browser } = await openTabInUserContext(userContextId); + + await Promise.all([ + waitForNewCookie(), + SpecialPowers.spawn( + browser, + [cookie], + passedCookie => (content.document.cookie = passedCookie) + ), + ]); + + // Ensure the tab's session history is up-to-date. + await TabStateFlusher.flush(browser); + + // Remove the tab. + gBrowser.removeTab(tab); + } + + let state = JSON.parse(SessionStore.getBrowserState()); + is( + state.cookies.length, + USER_CONTEXTS.length, + "session restore should have each container's cookie" + ); +}); diff --git a/browser/components/sessionstore/test/browser_sizemodeBeforeMinimized.js b/browser/components/sessionstore/test/browser_sizemodeBeforeMinimized.js new file mode 100644 index 0000000000..a832e71bcf --- /dev/null +++ b/browser/components/sessionstore/test/browser_sizemodeBeforeMinimized.js @@ -0,0 +1,44 @@ +add_task(async function test() { + // Test for bugfix 384278. Confirms that sizemodeBeforeMinimized is set properly when window state is saved. + let win = await BrowserTestUtils.openNewBrowserWindow(); + + async function changeSizeMode(mode) { + let promise = BrowserTestUtils.waitForEvent(win, "sizemodechange"); + win[mode](); + await promise; + } + + function checkCurrentState(sizemodeBeforeMinimized) { + let state = ss.getWindowState(win); + let winState = state.windows[0]; + is( + winState.sizemodeBeforeMinimized, + sizemodeBeforeMinimized, + "sizemodeBeforeMinimized should match" + ); + } + + // Note: Uses ss.getWindowState(win); as a more time efficient alternative to forceSaveState(); (causing timeouts). + // Simulates FF restart. + + if (win.windowState != win.STATE_NORMAL) { + await changeSizeMode("restore"); + } + ss.getWindowState(win); + await changeSizeMode("minimize"); + checkCurrentState("normal"); + + // Need to create new window or test will timeout on linux. + await BrowserTestUtils.closeWindow(win); + win = await BrowserTestUtils.openNewBrowserWindow(); + + if (win.windowState != win.STATE_MAXIMIZED) { + await changeSizeMode("maximize"); + } + ss.getWindowState(win); + await changeSizeMode("minimize"); + checkCurrentState("maximized"); + + // Clean up. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_speculative_connect.html b/browser/components/sessionstore/test/browser_speculative_connect.html new file mode 100644 index 0000000000..a0fb88e0a6 --- /dev/null +++ b/browser/components/sessionstore/test/browser_speculative_connect.html @@ -0,0 +1,8 @@ +<html> +<header> + <title>Dummy html page to test speculative connect</title> +</header> +<body> + Hello Speculative Connect +</body> +</html> diff --git a/browser/components/sessionstore/test/browser_speculative_connect.js b/browser/components/sessionstore/test/browser_speculative_connect.js new file mode 100644 index 0000000000..bece5e7baa --- /dev/null +++ b/browser/components/sessionstore/test/browser_speculative_connect.js @@ -0,0 +1,145 @@ +const TEST_URLS = [ + "about:buildconfig", + "http://mochi.test:8888/browser/browser/components/sessionstore/test/browser_speculative_connect.html", + "", +]; + +/** + * This will open tabs in browser. This will also make the last tab + * inserted to be the selected tab. + */ +async function openTabs(win) { + for (let i = 0; i < TEST_URLS.length; ++i) { + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URLS[i]); + } +} + +add_task(async function speculative_connect_restore_on_demand() { + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", true); + is( + Services.prefs.getBoolPref("browser.sessionstore.restore_on_demand"), + true, + "We're restoring on demand" + ); + forgetClosedWindows(); + + // Open a new window and populate with tabs. + let win = await promiseNewWindowLoaded(); + await openTabs(win); + + // Close the window. + await BrowserTestUtils.closeWindow(win); + + // Reopen a window. + let newWin = undoCloseWindow(0); + // Make sure we wait until this window is restored. + await promiseWindowRestored(newWin); + + let tabs = newWin.gBrowser.tabs; + is(tabs.length, TEST_URLS.length + 1, "Restored right number of tabs"); + + let e = new MouseEvent("mouseover"); + + // First tab should be ignored, since it's the default blank tab when we open a new window. + + // Trigger a mouse enter on second tab. + tabs[1].dispatchEvent(e); + ok( + !tabs[1].__test_connection_prepared, + "Second tab doesn't have a connection prepared" + ); + is(tabs[1].__test_connection_url, TEST_URLS[0], "Second tab has correct url"); + ok( + SessionStore.getLazyTabValue(tabs[1], "connectionPrepared"), + "Second tab should have connectionPrepared flag after hovered" + ); + + // Trigger a mouse enter on third tab. + tabs[2].dispatchEvent(e); + ok(tabs[2].__test_connection_prepared, "Third tab has a connection prepared"); + is(tabs[2].__test_connection_url, TEST_URLS[1], "Third tab has correct url"); + ok( + SessionStore.getLazyTabValue(tabs[2], "connectionPrepared"), + "Third tab should have connectionPrepared flag after hovered" + ); + + // Last tab is the previously selected tab. + tabs[3].dispatchEvent(e); + is( + SessionStore.getLazyTabValue(tabs[3], "connectionPrepared"), + undefined, + "Previous selected tab shouldn't have connectionPrepared flag" + ); + is( + tabs[3].__test_connection_prepared, + undefined, + "Previous selected tab should not have a connection prepared" + ); + is( + tabs[3].__test_connection_url, + undefined, + "Previous selected tab should not have a connection prepared" + ); + + await BrowserTestUtils.closeWindow(newWin); +}); + +add_task(async function speculative_connect_restore_automatically() { + Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", false); + is( + Services.prefs.getBoolPref("browser.sessionstore.restore_on_demand"), + false, + "We're restoring automatically" + ); + forgetClosedWindows(); + + // Open a new window and populate with tabs. + let win = await promiseNewWindowLoaded(); + await openTabs(win); + + // Close the window. + await BrowserTestUtils.closeWindow(win); + + // Reopen a window. + let newWin = undoCloseWindow(0); + // Make sure we wait until this window is restored. + await promiseWindowRestored(newWin); + + let tabs = newWin.gBrowser.tabs; + is(tabs.length, TEST_URLS.length + 1, "Restored right number of tabs"); + + // First tab is ignored, since it's the default tab open when we open new window + + // Second tab. + ok( + !tabs[1].__test_connection_prepared, + "Second tab doesn't have a connection prepared" + ); + is( + tabs[1].__test_connection_url, + TEST_URLS[0], + "Second tab has correct host url" + ); + + // Third tab. + ok(tabs[2].__test_connection_prepared, "Third tab has a connection prepared"); + is( + tabs[2].__test_connection_url, + TEST_URLS[1], + "Third tab has correct host url" + ); + + // Last tab is the previously selected tab. + is( + tabs[3].__test_connection_prepared, + undefined, + "Selected tab should not have a connection prepared" + ); + is( + tabs[3].__test_connection_url, + undefined, + "Selected tab should not have a connection prepared" + ); + + await BrowserTestUtils.closeWindow(newWin); +}); diff --git a/browser/components/sessionstore/test/browser_swapDocShells.js b/browser/components/sessionstore/test/browser_swapDocShells.js new file mode 100644 index 0000000000..a53385a105 --- /dev/null +++ b/browser/components/sessionstore/test/browser_swapDocShells.js @@ -0,0 +1,40 @@ +"use strict"; + +add_task(async function() { + let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab( + gBrowser, + "about:mozilla" + )); + await promiseBrowserLoaded(gBrowser.selectedBrowser); + + let win = gBrowser.replaceTabWithWindow(tab); + await promiseDelayedStartupFinished(win); + await promiseBrowserHasURL(win.gBrowser.browsers[0], "about:mozilla"); + + win.duplicateTabIn(win.gBrowser.selectedTab, "tab"); + await promiseTabRestored(win.gBrowser.tabs[1]); + + let browser = win.gBrowser.browsers[1]; + is(browser.currentURI.spec, "about:mozilla", "tab was duplicated"); + + await BrowserTestUtils.closeWindow(win); +}); + +function promiseDelayedStartupFinished(win) { + return new Promise(resolve => { + whenDelayedStartupFinished(win, resolve); + }); +} + +function promiseBrowserHasURL(browser, url) { + let promise = Promise.resolve(); + + if ( + browser.contentDocument.readyState === "complete" && + browser.currentURI.spec === url + ) { + return promise; + } + + return promise.then(() => promiseBrowserHasURL(browser, url)); +} diff --git a/browser/components/sessionstore/test/browser_switch_remoteness.js b/browser/components/sessionstore/test/browser_switch_remoteness.js new file mode 100644 index 0000000000..7839033c6a --- /dev/null +++ b/browser/components/sessionstore/test/browser_switch_remoteness.js @@ -0,0 +1,53 @@ +"use strict"; + +const URL = "http://example.com/browser_switch_remoteness_"; + +function countHistoryEntries(browser, expected) { + return SpecialPowers.spawn(browser, [{ expected }], async function(args) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + let history = webNavigation.sessionHistory; + Assert.equal( + history && history.count, + args.expected, + "correct number of shistory entries" + ); + }); +} + +add_task(async function() { + // Open a new window. + let win = await promiseNewWindowLoaded(); + + // Add a new tab. + let tab = BrowserTestUtils.addTab(win.gBrowser, "about:blank"); + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + ok(browser.isRemoteBrowser, "browser is remote"); + + // Get the maximum number of preceding entries to save. + const MAX_BACK = Services.prefs.getIntPref( + "browser.sessionstore.max_serialize_back" + ); + ok(MAX_BACK > -1, "check that the default has a value that caps data"); + + // Load more pages than we would save to disk on a clean shutdown. + for (let i = 0; i < MAX_BACK + 2; i++) { + BrowserTestUtils.loadURI(browser, URL + i); + await promiseBrowserLoaded(browser); + ok(browser.isRemoteBrowser, "browser is still remote"); + } + + // Check we have the right number of shistory entries. + await countHistoryEntries(browser, MAX_BACK + 2); + + // Load a non-remote page. + BrowserTestUtils.loadURI(browser, "about:robots"); + await promiseBrowserLoaded(browser); + ok(!browser.isRemoteBrowser, "browser is not remote anymore"); + + // Check that we didn't lose any shistory entries. + await countHistoryEntries(browser, MAX_BACK + 3); + + // Cleanup. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/sessionstore/test/browser_tab_label_during_restore.js b/browser/components/sessionstore/test/browser_tab_label_during_restore.js new file mode 100644 index 0000000000..e4bd3598d2 --- /dev/null +++ b/browser/components/sessionstore/test/browser_tab_label_during_restore.js @@ -0,0 +1,182 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that we don't do unnecessary tab label changes while restoring a tab. + */ + +add_task(async function() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.sessionstore.restore_on_demand", true], + ["browser.sessionstore.restore_tabs_lazily", true], + ], + }); + + const BACKUP_STATE = SessionStore.getBrowserState(); + const REMOTE_URL = "http://www.example.com/"; + const ABOUT_ROBOTS_URI = "about:robots"; + const ABOUT_ROBOTS_TITLE = "Gort! Klaatu barada nikto!"; + const NO_TITLE_URL = "data:text/plain,foo"; + const EMPTY_TAB_TITLE = gBrowser.tabContainer.emptyTabTitle; + + function observeLabelChanges(tab, expectedLabels) { + let seenLabels = [tab.label]; + function TabAttrModifiedListener(event) { + if (event.detail.changed.some(attr => attr == "label")) { + seenLabels.push(tab.label); + } + } + tab.addEventListener("TabAttrModified", TabAttrModifiedListener); + return async () => { + await BrowserTestUtils.waitForCondition( + () => seenLabels.length == expectedLabels.length, + "saw " + seenLabels.length + " TabAttrModified events" + ); + tab.removeEventListener("TabAttrModified", TabAttrModifiedListener); + is( + JSON.stringify(seenLabels), + JSON.stringify(expectedLabels || []), + "observed tab label changes" + ); + }; + } + + info("setting test browser state"); + let browserLoadedPromise = BrowserTestUtils.firstBrowserLoaded(window, false); + await promiseBrowserState({ + windows: [ + { + tabs: [ + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: ABOUT_ROBOTS_URI, triggeringPrincipal_base64 }] }, + { entries: [{ url: REMOTE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: NO_TITLE_URL, triggeringPrincipal_base64 }] }, + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + ], + }, + ], + }); + let [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + is(gBrowser.selectedTab, tab1, "first tab is selected"); + + await browserLoadedPromise; + const REMOTE_TITLE = tab1.linkedBrowser.contentTitle; + is( + tab1.linkedBrowser.currentURI.spec, + REMOTE_URL, + "correct URL loaded in first tab" + ); + is(typeof REMOTE_TITLE, "string", "content title is a string"); + isnot(REMOTE_TITLE.length, 0, "content title isn't empty"); + isnot(REMOTE_TITLE, REMOTE_URL, "content title is different from the URL"); + is(tab1.label, REMOTE_TITLE, "first tab displays content title"); + ok( + document.title.startsWith(REMOTE_TITLE), + "title bar displays content title" + ); + ok(tab2.hasAttribute("pending"), "second tab is pending"); + ok(tab3.hasAttribute("pending"), "third tab is pending"); + ok(tab4.hasAttribute("pending"), "fourth tab is pending"); + is(tab5.label, EMPTY_TAB_TITLE, "fifth tab dislpays empty tab title"); + + info("selecting the second tab"); + // The fix for bug 1364127 caused about: pages' initial tab titles to show + // their about: URIs until their actual page titles are known, e.g. + // "about:addons" -> "Add-ons Manager". This is bug 1371896. Previously, + // about: pages' initial tab titles were blank until the page title was known. + let finishObservingLabelChanges = observeLabelChanges(tab2, [ + ABOUT_ROBOTS_URI, + ABOUT_ROBOTS_TITLE, + ]); + browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab2.linkedBrowser, + false, + ABOUT_ROBOTS_URI + ); + gBrowser.selectedTab = tab2; + await browserLoadedPromise; + ok(!tab2.hasAttribute("pending"), "second tab isn't pending anymore"); + await finishObservingLabelChanges(); + ok( + document.title.startsWith(ABOUT_ROBOTS_TITLE), + "title bar displays content title" + ); + + info("selecting the third tab"); + finishObservingLabelChanges = observeLabelChanges(tab3, [ + "example.com/", + REMOTE_TITLE, + ]); + browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab3.linkedBrowser, + false, + REMOTE_URL + ); + gBrowser.selectedTab = tab3; + await browserLoadedPromise; + ok(!tab3.hasAttribute("pending"), "third tab isn't pending anymore"); + await finishObservingLabelChanges(); + ok( + document.title.startsWith(REMOTE_TITLE), + "title bar displays content title" + ); + + info("selecting the fourth tab"); + finishObservingLabelChanges = observeLabelChanges(tab4, [NO_TITLE_URL]); + browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab4.linkedBrowser, + false, + NO_TITLE_URL + ); + gBrowser.selectedTab = tab4; + await browserLoadedPromise; + ok(!tab4.hasAttribute("pending"), "fourth tab isn't pending anymore"); + await finishObservingLabelChanges(); + is( + document.title, + document.getElementById("bundle_brand").getString("brandFullName"), + "title bar doesn't display content title since page doesn't have one" + ); + + info("restoring the modified browser state"); + gBrowser.selectedTab = tab3; + await TabStateFlusher.flushWindow(window); + await promiseBrowserState(SessionStore.getBrowserState()); + [tab1, tab2, tab3, tab4, tab5] = gBrowser.tabs; + is(tab3, gBrowser.selectedTab, "third tab is selected after restoring"); + ok( + document.title.startsWith(REMOTE_TITLE), + "title bar displays content title" + ); + ok(tab1.hasAttribute("pending"), "first tab is pending after restoring"); + ok(tab2.hasAttribute("pending"), "second tab is pending after restoring"); + is(tab2.label, ABOUT_ROBOTS_TITLE, "second tab displays content title"); + ok(!tab3.hasAttribute("pending"), "third tab is not pending after restoring"); + is( + tab3.label, + REMOTE_TITLE, + "third tab displays content title in pending state" + ); + ok(tab4.hasAttribute("pending"), "fourth tab is pending after restoring"); + is(tab4.label, NO_TITLE_URL, "fourth tab displays URL"); + is(tab5.label, EMPTY_TAB_TITLE, "fifth tab still displays empty tab title"); + + info("selecting the first tab"); + finishObservingLabelChanges = observeLabelChanges(tab1, [REMOTE_TITLE]); + let tabContentRestored = TestUtils.topicObserved( + "sessionstore-debug-tab-restored" + ); + gBrowser.selectedTab = tab1; + ok( + document.title.startsWith(REMOTE_TITLE), + "title bar displays content title" + ); + await tabContentRestored; + ok(!tab1.hasAttribute("pending"), "first tab isn't pending anymore"); + await finishObservingLabelChanges(); + + await promiseBrowserState(BACKUP_STATE); +}); diff --git a/browser/components/sessionstore/test/browser_tabicon_after_bg_tab_crash.js b/browser/components/sessionstore/test/browser_tabicon_after_bg_tab_crash.js new file mode 100644 index 0000000000..daa0a5a8c3 --- /dev/null +++ b/browser/components/sessionstore/test/browser_tabicon_after_bg_tab_crash.js @@ -0,0 +1,52 @@ +"use strict"; + +const FAVICON = + "data:image/gif;base64,R0lGODlhCwALAIAAAAAA3pn/ZiH5BAEAAAEALAAAAAALAAsAAAIUhA+hkcuO4lmNVindo7qyrIXiGBYAOw=="; +const PAGE_URL = `data:text/html, +<html> + <head> + <link rel="shortcut icon" href="${FAVICON}"> + </head> + <body> + Favicon! + </body> +</html>`; + +/** + * Tests that if a background tab crashes that it doesn't + * lose the favicon in the tab. + */ +add_task(async function test_tabicon_after_bg_tab_crash() { + let originalTab = gBrowser.selectedTab; + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE_URL, + }, + async function(browser) { + // Because there is debounce logic in ContentLinkHandler.jsm to reduce the + // favicon loads, we have to wait some time before checking that icon was + // stored properly. + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon() != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + Assert.equal(browser.mIconURL, FAVICON, "Favicon is correctly set."); + await BrowserTestUtils.switchTab(gBrowser, originalTab); + await BrowserTestUtils.crashFrame( + browser, + false /* shouldShowTabCrashPage */ + ); + Assert.equal( + browser.mIconURL, + FAVICON, + "Favicon is still set after crash." + ); + } + ); +}); diff --git a/browser/components/sessionstore/test/browser_tabs_in_urlbar.js b/browser/components/sessionstore/test/browser_tabs_in_urlbar.js new file mode 100644 index 0000000000..f64afda33c --- /dev/null +++ b/browser/components/sessionstore/test/browser_tabs_in_urlbar.js @@ -0,0 +1,155 @@ +/* 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/. */ + +/** + * Tests that tabs which aren't displayed yet (i.e. need to be reloaded) are + * still displayed in the address bar results. + */ + +const RESTRICT_TOKEN_OPENPAGE = "%"; + +const { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); + +const { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); + +const { UrlbarProviderOpenTabs } = ChromeUtils.importESModule( + "resource:///modules/UrlbarProviderOpenTabs.sys.mjs" +); + +const { UrlbarTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" +); + +var stateBackup = ss.getBrowserState(); + +add_setup(async function() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the pref to true so we know exactly how many tabs should be restoring at + // any given time. This guarantees that a finishing load won't start another. + ["browser.sessionstore.restore_on_demand", true], + // Don't restore tabs lazily. + ["browser.sessionstore.restore_tabs_lazily", false], + ], + }); + + registerCleanupFunction(() => { + ss.setBrowserState(stateBackup); + }); + + info("Waiting for the Places DB to be initialized"); + await PlacesUtils.promiseLargeCacheDBConnection(); + await UrlbarProviderOpenTabs.promiseDBPopulated; +}); + +add_task(async function test_unrestored_tabs_listed() { + const state = { + windows: [ + { + tabs: [ + { + entries: [ + { url: "http://example.org/#1", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org/#2", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org/#3", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "http://example.org/#4", triggeringPrincipal_base64 }, + ], + }, + ], + selected: 1, + }, + ], + }; + + const tabsForEnsure = new Set(); + state.windows[0].tabs.forEach(function(tab) { + tabsForEnsure.add(tab.entries[0].url); + }); + + let tabsRestoring = 0; + let tabsRestored = 0; + + await new Promise(resolve => { + function handleEvent(aEvent) { + if (aEvent.type == "SSTabRestoring") { + tabsRestoring++; + } else { + tabsRestored++; + } + + if (tabsRestoring < state.windows[0].tabs.length || tabsRestored < 1) { + return; + } + + gBrowser.tabContainer.removeEventListener( + "SSTabRestoring", + handleEvent, + true + ); + gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + handleEvent, + true + ); + executeSoon(resolve); + } + + // currentURI is set before SSTabRestoring is fired, so we can sucessfully check + // after that has fired for all tabs. Since 1 tab will be restored though, we + // also need to wait for 1 SSTabRestored since currentURI will be set, unset, then set. + gBrowser.tabContainer.addEventListener("SSTabRestoring", handleEvent, true); + gBrowser.tabContainer.addEventListener("SSTabRestored", handleEvent, true); + ss.setBrowserState(JSON.stringify(state)); + }); + + // Ensure any database statements started by UrlbarProviderOpenTabs are + // complete before continuing. + await PlacesTestUtils.promiseAsyncUpdates(); + + // Remove the current tab from tabsForEnsure, because switch to tab doesn't + // suggest it. + tabsForEnsure.delete(gBrowser.currentURI.spec); + info("Searching open pages."); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: RESTRICT_TOKEN_OPENPAGE, + }); + const total = UrlbarTestUtils.getResultCount(window); + info(`Found ${total} matches`); + + // Check to see the expected uris and titles match up (in any order) + for (let i = 0; i < total; i++) { + const result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if (result.heuristic) { + info("Skip heuristic match"); + continue; + } + const url = result.url; + Assert.ok( + tabsForEnsure.has(url), + `Should have the found result '${url}' in the expected list of entries` + ); + // Remove the found entry from expected results. + tabsForEnsure.delete(url); + } + // Make sure there is no reported open page that is not open. + Assert.equal(tabsForEnsure.size, 0, "Should have found all the tabs"); +}); diff --git a/browser/components/sessionstore/test/browser_undoCloseById.js b/browser/components/sessionstore/test/browser_undoCloseById.js new file mode 100644 index 0000000000..7a513d730c --- /dev/null +++ b/browser/components/sessionstore/test/browser_undoCloseById.js @@ -0,0 +1,178 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +/** + * This test is for the undoCloseById function. + */ + +const { SessionStore } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionStore.sys.mjs" +); + +async function openWindow(url) { + let win = await promiseNewWindowLoaded(); + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY; + BrowserTestUtils.loadURI(win.gBrowser.selectedBrowser, url, { flags }); + await promiseBrowserLoaded(win.gBrowser.selectedBrowser, true, url); + return win; +} + +async function closeWindow(win) { + await BrowserTestUtils.closeWindow(win); + // Wait 20 ms to allow SessionStorage a chance to register the closed window. + await new Promise(resolve => setTimeout(resolve, 20)); +} + +add_task(async function test_undoCloseById() { + // Clear the lists of closed windows and tabs. + forgetClosedWindows(); + while (SessionStore.getClosedTabCount(window)) { + SessionStore.forgetClosedTab(window, 0); + } + + // Open a new window. + let win = await openWindow("about:robots"); + + // Open and close a tab. + await openAndCloseTab(win, "about:mozilla"); + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + + // Record the first closedId created. + let initialClosedId = SessionStore.getClosedTabData(win)[0].closedId; + + // Open and close another window. + let win2 = await openWindow("about:mozilla"); + await closeWindow(win2); // closedId == initialClosedId + 1 + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + + // Open and close another tab in the first window. + await openAndCloseTab(win, "about:robots"); // closedId == initialClosedId + 2 + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + + // Undo closing the second tab. + let tab = SessionStore.undoCloseById(initialClosedId + 2); + await promiseBrowserLoaded(tab.linkedBrowser); + is( + tab.linkedBrowser.currentURI.spec, + "about:robots", + "The expected tab was re-opened" + ); + + let notTab = SessionStore.undoCloseById(initialClosedId + 2); + is(notTab, undefined, "Re-opened tab cannot be unClosed again by closedId"); + + // Now the last closed object should be a window again. + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + + // Undo closing the first tab. + let tab2 = SessionStore.undoCloseById(initialClosedId); + await promiseBrowserLoaded(tab2.linkedBrowser); + is( + tab2.linkedBrowser.currentURI.spec, + "about:mozilla", + "The expected tab was re-opened" + ); + + // Close the two tabs we re-opened. + await promiseRemoveTabAndSessionState(tab); // closedId == initialClosedId + 3 + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + await promiseRemoveTabAndSessionState(tab2); // closedId == initialClosedId + 4 + is( + SessionStore.lastClosedObjectType, + "tab", + "The last closed object is a tab" + ); + + // Open another new window. + let win3 = await openWindow("about:mozilla"); + + // Close both windows. + await closeWindow(win); // closedId == initialClosedId + 5 + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + await closeWindow(win3); // closedId == initialClosedId + 6 + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + + // Undo closing the second window. + win = SessionStore.undoCloseById(initialClosedId + 6); + await BrowserTestUtils.waitForEvent(win, "load"); + + // Make sure we wait until this window is restored. + await BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "SSTabRestored" + ); + + is( + win.gBrowser.selectedBrowser.currentURI.spec, + "about:mozilla", + "The expected window was re-opened" + ); + + let notWin = SessionStore.undoCloseById(initialClosedId + 6); + is( + notWin, + undefined, + "Re-opened window cannot be unClosed again by closedId" + ); + + // Close the window again. + await closeWindow(win); + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); + + // Undo closing the first window. + win = SessionStore.undoCloseById(initialClosedId + 5); + + await BrowserTestUtils.waitForEvent(win, "load"); + + // Make sure we wait until this window is restored. + await BrowserTestUtils.waitForEvent( + win.gBrowser.tabContainer, + "SSTabRestored" + ); + + is( + win.gBrowser.selectedBrowser.currentURI.spec, + "about:robots", + "The expected window was re-opened" + ); + + // Close the window again. + await closeWindow(win); + is( + SessionStore.lastClosedObjectType, + "window", + "The last closed object is a window" + ); +}); diff --git a/browser/components/sessionstore/test/browser_unrestored_crashedTabs.js b/browser/components/sessionstore/test/browser_unrestored_crashedTabs.js new file mode 100644 index 0000000000..eb911bec14 --- /dev/null +++ b/browser/components/sessionstore/test/browser_unrestored_crashedTabs.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that if we have tabs that are still in the "click to + * restore" state, that if their browsers crash, that we don't + * show the crashed state for those tabs (since selecting them + * should restore them anyway). + */ + +const PREF = "browser.sessionstore.restore_on_demand"; +const PAGE = + "data:text/html,<html><body>A%20regular,%20everyday,%20normal%20page."; + +add_task(async function test() { + await pushPrefs([PREF, true]); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: PAGE, + }, + async function(browser) { + await TabStateFlusher.flush(browser); + + // We'll create a second "pending" tab. This is the one we'll + // ensure doesn't go to about:tabcrashed. We start it non-remote + // since this is how SessionStore creates all browsers before + // they are restored. + let unrestoredTab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + skipAnimation: true, + forceNotRemote: true, + }); + + let state = { + entries: [{ url: PAGE, triggeringPrincipal_base64 }], + }; + + ss.setTabState(unrestoredTab, JSON.stringify(state)); + + ok(!unrestoredTab.hasAttribute("crashed"), "tab is not crashed"); + ok(unrestoredTab.hasAttribute("pending"), "tab is pending"); + + // Now crash the selected browser. + await BrowserTestUtils.crashFrame(browser); + + ok(!unrestoredTab.hasAttribute("crashed"), "tab is still not crashed"); + ok(unrestoredTab.hasAttribute("pending"), "tab is still pending"); + + // Selecting the tab should now restore it. + gBrowser.selectedTab = unrestoredTab; + await promiseTabRestored(unrestoredTab); + + ok(!unrestoredTab.hasAttribute("crashed"), "tab is still not crashed"); + ok(!unrestoredTab.hasAttribute("pending"), "tab is no longer pending"); + + // The original tab should still be crashed + let originalTab = gBrowser.getTabForBrowser(browser); + ok(originalTab.hasAttribute("crashed"), "original tab is crashed"); + ok(!originalTab.isRemoteBrowser, "Should not be remote"); + + // We'd better be able to restore it still. + gBrowser.selectedTab = originalTab; + SessionStore.reviveCrashedTab(originalTab); + await promiseTabRestored(originalTab); + + // Clean up. + BrowserTestUtils.removeTab(unrestoredTab); + } + ); +}); diff --git a/browser/components/sessionstore/test/browser_upgrade_backup.js b/browser/components/sessionstore/test/browser_upgrade_backup.js new file mode 100644 index 0000000000..ae48aad485 --- /dev/null +++ b/browser/components/sessionstore/test/browser_upgrade_backup.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const Paths = SessionFile.Paths; +const PREF_UPGRADE = "browser.sessionstore.upgradeBackup.latestBuildID"; +const PREF_MAX_UPGRADE_BACKUPS = + "browser.sessionstore.upgradeBackup.maxUpgradeBackups"; + +/** + * Prepares tests by retrieving the current platform's build ID, clearing the + * build where the last backup was created and creating arbitrary JSON data + * for a new backup. + */ +function prepareTest() { + let result = {}; + + result.buildID = Services.appinfo.platformBuildID; + Services.prefs.setCharPref(PREF_UPGRADE, ""); + result.contents = { + "browser_upgrade_backup.js": Math.random(), + }; + + return result; +} + +/** + * Retrieves all upgrade backups and returns them in an array. + */ +async function getUpgradeBackups() { + let children = await IOUtils.getChildren(Paths.backups); + + return children.filter(path => path.startsWith(Paths.upgradeBackupPrefix)); +} + +add_setup(async function() { + // Wait until initialization is complete + await SessionStore.promiseInitialized; +}); + +add_task(async function test_upgrade_backup() { + let test = prepareTest(); + info("Let's check if we create an upgrade backup"); + await SessionFile.wipe(); + await IOUtils.writeJSON(Paths.clean, test.contents, { + compress: true, + }); + info("Call `SessionFile.read()` to set state to 'clean'"); + await SessionFile.read(); + await SessionFile.write(""); // First call to write() triggers the backup + + Assert.equal( + Services.prefs.getCharPref(PREF_UPGRADE), + test.buildID, + "upgrade backup should be set" + ); + + Assert.ok( + await IOUtils.exists(Paths.upgradeBackup), + "upgrade backup file has been created" + ); + + let data = await IOUtils.readJSON(Paths.upgradeBackup, { decompress: true }); + Assert.deepEqual( + test.contents, + data, + "upgrade backup contains the expected contents" + ); + + info("Let's check that we don't overwrite this upgrade backup"); + let newContents = { + "something else entirely": Math.random(), + }; + await IOUtils.writeJSON(Paths.clean, newContents, { + compress: true, + }); + await SessionFile.write(""); // Next call to write() shouldn't trigger the backup + data = await IOUtils.readJSON(Paths.upgradeBackup, { decompress: true }); + Assert.deepEqual(test.contents, data, "upgrade backup hasn't changed"); +}); + +add_task(async function test_upgrade_backup_removal() { + let test = prepareTest(); + let maxUpgradeBackups = Preferences.get(PREF_MAX_UPGRADE_BACKUPS, 3); + info("Let's see if we remove backups if there are too many"); + await SessionFile.wipe(); + await IOUtils.writeJSON(Paths.clean, test.contents, { + compress: true, + }); + info("Call `SessionFile.read()` to set state to 'clean'"); + await SessionFile.read(); + + // create dummy backups + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20080101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20090101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20100101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20110101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20120101010101", "", { + compress: true, + }); + await IOUtils.writeUTF8(Paths.upgradeBackupPrefix + "20130101010101", "", { + compress: true, + }); + + // get currently existing backups + let backups = await getUpgradeBackups(); + + info("Write the session to disk and perform a backup"); + await SessionFile.write(""); // First call to write() triggers the backup and the cleanup + + // a new backup should have been created (and still exist) + is( + Services.prefs.getCharPref(PREF_UPGRADE), + test.buildID, + "upgrade backup should be set" + ); + Assert.ok( + await IOUtils.exists(Paths.upgradeBackup), + "upgrade backup file has been created" + ); + + // get currently existing backups and check their count + let newBackups = await getUpgradeBackups(); + is( + newBackups.length, + maxUpgradeBackups, + "expected number of backups are present after removing old backups" + ); + + // find all backups that were created during the last call to `SessionFile.write("");` + // ie, filter out all the backups that have already been present before the call + newBackups = newBackups.filter(function(backup) { + return !backups.includes(backup); + }); + + // check that exactly one new backup was created + is(newBackups.length, 1, "one new backup was created that was not removed"); + + await SessionFile.write(""); // Second call to write() should not trigger anything + + backups = await getUpgradeBackups(); + is( + backups.length, + maxUpgradeBackups, + "second call to SessionFile.write() didn't create or remove more backups" + ); +}); diff --git a/browser/components/sessionstore/test/browser_urlbarSearchMode.js b/browser/components/sessionstore/test/browser_urlbarSearchMode.js new file mode 100644 index 0000000000..af00767c2e --- /dev/null +++ b/browser/components/sessionstore/test/browser_urlbarSearchMode.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test makes sure that the urlbar's search mode is correctly preserved. + */ + +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", +}); + +UrlbarTestUtils.init(this); + +add_task(async function test() { + // Open the urlbar view and enter search mode. + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test", + }); + await UrlbarTestUtils.enterSearchMode(window, { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + }); + + // The search mode should be in the tab state. + let state = JSON.parse(ss.getTabState(gBrowser.selectedTab)); + Assert.ok( + "searchMode" in state, + "state.searchMode is present after entering search mode" + ); + Assert.deepEqual( + state.searchMode, + { + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + entry: "oneoff", + isPreview: false, + }, + "state.searchMode is correct" + ); + + // Exit search mode. + await UrlbarTestUtils.exitSearchMode(window); + + // The search mode should not be in the tab state. + let newState = JSON.parse(ss.getTabState(gBrowser.selectedTab)); + Assert.ok( + !newState.searchMode, + "state.searchMode is not present after exiting search mode" + ); + + await UrlbarTestUtils.promisePopupClose(window); +}); diff --git a/browser/components/sessionstore/test/browser_userTyped_restored_after_discard.js b/browser/components/sessionstore/test/browser_userTyped_restored_after_discard.js new file mode 100644 index 0000000000..9785231ac2 --- /dev/null +++ b/browser/components/sessionstore/test/browser_userTyped_restored_after_discard.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function testDiscardWithNotLoadedUserTypedValue() { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com" + ); + + // Make sure we flushed the state at least once (otherwise the fix + // for Bug 1422588 would make SessionStore.resetBrowserToLazyState + // to still store the user typed value into the tab state cache + // even when the user typed value was not yet being loading when + // the tab got discarded). + await TabStateFlusher.flush(tab1.linkedBrowser); + + tab1.linkedBrowser.userTypedValue = "mockUserTypedValue"; + + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + + let waitForTabDiscarded = BrowserTestUtils.waitForEvent( + tab1, + "TabBrowserDiscarded" + ); + gBrowser.discardBrowser(tab1); + await waitForTabDiscarded; + + const promiseTabLoaded = BrowserTestUtils.browserLoaded( + tab1.linkedBrowser, + false, + "http://example.com/" + ); + await BrowserTestUtils.switchTab(gBrowser, tab1); + info("Wait for the restored tab to load http://example.com"); + await promiseTabLoaded; + is( + tab1.linkedBrowser.currentURI.spec, + "http://example.com/", + "Restored discarded tab has loaded the expected url" + ); + + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab1); +}); diff --git a/browser/components/sessionstore/test/browser_windowRestore_perwindowpb.js b/browser/components/sessionstore/test/browser_windowRestore_perwindowpb.js new file mode 100644 index 0000000000..23beb69331 --- /dev/null +++ b/browser/components/sessionstore/test/browser_windowRestore_perwindowpb.js @@ -0,0 +1,32 @@ +/* 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/. */ + +// This test checks that closed private windows can't be restored + +function test() { + waitForExplicitFinish(); + + // Purging the list of closed windows + forgetClosedWindows(); + + // Load a private window, then close it + // and verify it doesn't get remembered for restoring + whenNewWindowLoaded({ private: true }, function(win) { + info("The private window got loaded"); + win.addEventListener( + "SSWindowClosing", + function() { + executeSoon(function() { + is( + ss.getClosedWindowCount(), + 0, + "The private window should not have been stored" + ); + }); + }, + { once: true } + ); + BrowserTestUtils.closeWindow(win).then(finish); + }); +} diff --git a/browser/components/sessionstore/test/browser_windowStateContainer.js b/browser/components/sessionstore/test/browser_windowStateContainer.js new file mode 100644 index 0000000000..d416fb8e3d --- /dev/null +++ b/browser/components/sessionstore/test/browser_windowStateContainer.js @@ -0,0 +1,170 @@ +"use strict"; + +requestLongerTimeout(2); + +add_setup(async function() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount", 1]], + }); +}); + +function promiseTabsRestored(win, nExpected) { + return new Promise(resolve => { + let nReceived = 0; + function handler(event) { + if (++nReceived === nExpected) { + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + handler, + true + ); + resolve(); + } + } + win.gBrowser.tabContainer.addEventListener("SSTabRestored", handler, true); + }); +} + +add_task(async function() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + // Create 4 tabs with different userContextId. + for (let userContextId = 1; userContextId < 5; userContextId++) { + let tab = BrowserTestUtils.addTab(win.gBrowser, "http://example.com/", { + userContextId, + }); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + } + + // Move the default tab of window to the end. + // We want the 1st tab to have non-default userContextId, so later when we + // restore into win2 we can test restore into an existing tab with different + // userContextId. + win.gBrowser.moveTabTo(win.gBrowser.tabs[0], win.gBrowser.tabs.length - 1); + + let winState = ss.getWindowState(win); + + for (let i = 0; i < 4; i++) { + Assert.equal( + winState.windows[0].tabs[i].userContextId, + i + 1, + "1st Window: tabs[" + i + "].userContextId should exist." + ); + } + + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + + // Create tabs with different userContextId, but this time we create them with + // fewer tabs and with different order with win. + for (let userContextId = 3; userContextId > 0; userContextId--) { + let tab = BrowserTestUtils.addTab(win2.gBrowser, "http://example.com/", { + userContextId, + }); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + } + + let tabsRestored = promiseTabsRestored(win2, 5); + await setWindowState(win2, winState, true); + await tabsRestored; + + for (let i = 0; i < 4; i++) { + let browser = win2.gBrowser.tabs[i].linkedBrowser; + await ContentTask.spawn(browser, { expectedId: i + 1 }, async function( + args + ) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + + Assert.equal( + content.document.nodePrincipal.originAttributes.userContextId, + args.expectedId, + "The document has the correct userContextId" + ); + }); + } + + // Test the last tab, which doesn't have userContextId. + let browser = win2.gBrowser.tabs[4].linkedBrowser; + await SpecialPowers.spawn(browser, [{ expectedId: 0 }], async function(args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + + Assert.equal( + content.document.nodePrincipal.originAttributes.userContextId, + args.expectedId, + "The document has the correct userContextId" + ); + }); + + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(win2); +}); + +add_task(async function() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + await TabStateFlusher.flush(win.gBrowser.selectedBrowser); + + let tab = BrowserTestUtils.addTab(win.gBrowser, "http://example.com/", { + userContextId: 1, + }); + await promiseBrowserLoaded(tab.linkedBrowser); + await TabStateFlusher.flush(tab.linkedBrowser); + + // win should have 1 default tab, and 1 container tab. + Assert.equal(win.gBrowser.tabs.length, 2, "win should have 2 tabs"); + + let winState = ss.getWindowState(win); + + for (let i = 0; i < 2; i++) { + Assert.equal( + winState.windows[0].tabs[i].userContextId, + i, + "1st Window: tabs[" + i + "].userContextId should be " + i + ); + } + + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + + let tab2 = BrowserTestUtils.addTab(win2.gBrowser, "http://example.com/", { + userContextId: 1, + }); + await promiseBrowserLoaded(tab2.linkedBrowser); + await TabStateFlusher.flush(tab2.linkedBrowser); + + // Move the first normal tab to end, so the first tab of win2 will be a + // container tab. + win2.gBrowser.moveTabTo(win2.gBrowser.tabs[0], win2.gBrowser.tabs.length - 1); + await TabStateFlusher.flush(win2.gBrowser.tabs[0].linkedBrowser); + + let tabsRestored = promiseTabsRestored(win2, 2); + await setWindowState(win2, winState, true); + await tabsRestored; + + for (let i = 0; i < 2; i++) { + let browser = win2.gBrowser.tabs[i].linkedBrowser; + await ContentTask.spawn(browser, { expectedId: i }, async function(args) { + Assert.equal( + docShell.getOriginAttributes().userContextId, + args.expectedId, + "The docShell has the correct userContextId" + ); + + Assert.equal( + content.document.nodePrincipal.originAttributes.userContextId, + args.expectedId, + "The document has the correct userContextId" + ); + }); + } + + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(win2); +}); diff --git a/browser/components/sessionstore/test/coopHeaderCommon.sjs b/browser/components/sessionstore/test/coopHeaderCommon.sjs new file mode 100644 index 0000000000..919228f550 --- /dev/null +++ b/browser/components/sessionstore/test/coopHeaderCommon.sjs @@ -0,0 +1,31 @@ +function handleRequest(request, response) { + Cu.importGlobalProperties(["URLSearchParams"]); + let { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); + let query = new URLSearchParams(request.queryString); + + response.setHeader("Cross-Origin-Opener-Policy", "same-origin", false); + response.setHeader("Cross-Origin-Embedder-Policy", "require-corp", false); + + var fileRoot = query.get("fileRoot"); + + // Get the desired file + var file; + getObjectState("SERVER_ROOT", function(serverRoot) { + file = serverRoot.getFile(fileRoot); + }); + + // Set up the file streams to read in the file as UTF-8 + let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + + fstream.init(file, -1, 0, 0); + + // Read the file + let available = fstream.available(); + let data = + available > 0 ? NetUtil.readInputStreamToString(fstream, available) : ""; + fstream.close(); + + response.write(data); +} diff --git a/browser/components/sessionstore/test/coop_coep.html b/browser/components/sessionstore/test/coop_coep.html new file mode 100644 index 0000000000..9fe6f7a03e --- /dev/null +++ b/browser/components/sessionstore/test/coop_coep.html @@ -0,0 +1,6 @@ +<!DOCTYPE html> + +<html> + <body> + </body> +</html> diff --git a/browser/components/sessionstore/test/coop_coep.html^headers^ b/browser/components/sessionstore/test/coop_coep.html^headers^ new file mode 100644 index 0000000000..5f8621ef83 --- /dev/null +++ b/browser/components/sessionstore/test/coop_coep.html^headers^ @@ -0,0 +1,2 @@ +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin diff --git a/browser/components/sessionstore/test/empty.html b/browser/components/sessionstore/test/empty.html new file mode 100644 index 0000000000..ba0056bc32 --- /dev/null +++ b/browser/components/sessionstore/test/empty.html @@ -0,0 +1,6 @@ +<!DOCTYPE html> +<meta charset="UTF-8"> +<html> + <body> + </body> +</html> diff --git a/browser/components/sessionstore/test/file_async_duplicate_tab.html b/browser/components/sessionstore/test/file_async_duplicate_tab.html new file mode 100644 index 0000000000..17f1c080ea --- /dev/null +++ b/browser/components/sessionstore/test/file_async_duplicate_tab.html @@ -0,0 +1 @@ +<a href=#>clickme</a> diff --git a/browser/components/sessionstore/test/file_async_flushes.html b/browser/components/sessionstore/test/file_async_flushes.html new file mode 100644 index 0000000000..17f1c080ea --- /dev/null +++ b/browser/components/sessionstore/test/file_async_flushes.html @@ -0,0 +1 @@ +<a href=#>clickme</a> diff --git a/browser/components/sessionstore/test/file_formdata_password.html b/browser/components/sessionstore/test/file_formdata_password.html new file mode 100644 index 0000000000..0f072c31e1 --- /dev/null +++ b/browser/components/sessionstore/test/file_formdata_password.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + <!-- Text/Password in the name indicates the type and the position of 'Value' + indicates when the value gets set relative to the type changes. --> + <input id="TextValue"> + <input id="TextValuePassword"> + <input id="TextPasswordValue"> + + <input id="PasswordValueText" type="password"> + <input id="PasswordTextValue" type="password"> + <input id="PasswordValue" type="password"> + </body> +</html> diff --git a/browser/components/sessionstore/test/file_sessionHistory_hashchange.html b/browser/components/sessionstore/test/file_sessionHistory_hashchange.html new file mode 100644 index 0000000000..4b64fc180a --- /dev/null +++ b/browser/components/sessionstore/test/file_sessionHistory_hashchange.html @@ -0,0 +1 @@ +<a id=a href=#>clickme</a> diff --git a/browser/components/sessionstore/test/head.js b/browser/components/sessionstore/test/head.js new file mode 100644 index 0000000000..4150f826c6 --- /dev/null +++ b/browser/components/sessionstore/test/head.js @@ -0,0 +1,755 @@ +/* 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/. */ + +const { E10SUtils } = ChromeUtils.importESModule( + "resource://gre/modules/E10SUtils.sys.mjs" +); +const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + +const TAB_STATE_NEEDS_RESTORE = 1; +const TAB_STATE_RESTORING = 2; + +const ROOT = getRootDirectory(gTestPath); +const HTTPROOT = ROOT.replace( + "chrome://mochitests/content/", + "http://example.com/" +); +const HTTPSROOT = ROOT.replace( + "chrome://mochitests/content/", + "https://example.com/" +); + +const { SessionSaver } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionSaver.sys.mjs" +); +const { SessionFile } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); +const { TabState } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabState.sys.mjs" +); +const { TabStateFlusher } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabStateFlusher.sys.mjs" +); +const ss = SessionStore; + +// Some tests here assume that all restored tabs are loaded without waiting for +// the user to bring them to the foreground. We ensure this by resetting the +// related preference (see the "firefox.js" defaults file for details). +Services.prefs.setBoolPref("browser.sessionstore.restore_on_demand", false); +registerCleanupFunction(function() { + Services.prefs.clearUserPref("browser.sessionstore.restore_on_demand"); +}); + +// Obtain access to internals +Services.prefs.setBoolPref("browser.sessionstore.debug", true); +registerCleanupFunction(function() { + Services.prefs.clearUserPref("browser.sessionstore.debug"); +}); + +// This kicks off the search service used on about:home and allows the +// session restore tests to be run standalone without triggering errors. +Cc["@mozilla.org/browser/clh;1"].getService(Ci.nsIBrowserHandler).defaultArgs; + +function provideWindow(aCallback, aURL, aFeatures) { + function callbackSoon(aWindow) { + executeSoon(function executeCallbackSoon() { + aCallback(aWindow); + }); + } + + let win = openDialog( + AppConstants.BROWSER_CHROME_URL, + "", + aFeatures || "chrome,all,dialog=no", + aURL || "about:blank" + ); + whenWindowLoaded(win, function onWindowLoaded(aWin) { + if (!aURL) { + info("Loaded a blank window."); + callbackSoon(aWin); + return; + } + + aWin.gBrowser.selectedBrowser.addEventListener( + "load", + function() { + callbackSoon(aWin); + }, + { capture: true, once: true } + ); + }); +} + +// This assumes that tests will at least have some state/entries +function waitForBrowserState(aState, aSetStateCallback) { + if (typeof aState == "string") { + aState = JSON.parse(aState); + } + if (typeof aState != "object") { + throw new TypeError( + "Argument must be an object or a JSON representation of an object" + ); + } + let windows = [window]; + let tabsRestored = 0; + let expectedTabsRestored = 0; + let expectedWindows = aState.windows.length; + let windowsOpen = 1; + let listening = false; + let windowObserving = false; + let restoreHiddenTabs = Services.prefs.getBoolPref( + "browser.sessionstore.restore_hidden_tabs" + ); + // This should match the |restoreTabsLazily| value that + // SessionStore.restoreWindow() uses. + let restoreTabsLazily = + Services.prefs.getBoolPref("browser.sessionstore.restore_on_demand") && + Services.prefs.getBoolPref("browser.sessionstore.restore_tabs_lazily"); + + aState.windows.forEach(function(winState) { + winState.tabs.forEach(function(tabState) { + if (!restoreTabsLazily && (restoreHiddenTabs || !tabState.hidden)) { + expectedTabsRestored++; + } + }); + }); + + // If there are only hidden tabs and restoreHiddenTabs = false, we still + // expect one of them to be restored because it gets shown automatically. + // Otherwise if lazy tab restore there will only be one tab restored per window. + if (!expectedTabsRestored) { + expectedTabsRestored = 1; + } else if (restoreTabsLazily) { + expectedTabsRestored = aState.windows.length; + } + + function onSSTabRestored(aEvent) { + if (++tabsRestored == expectedTabsRestored) { + // Remove the event listener from each window + windows.forEach(function(win) { + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + onSSTabRestored, + true + ); + }); + listening = false; + info("running " + aSetStateCallback.name); + executeSoon(aSetStateCallback); + } + } + + // Used to add our listener to further windows so we can catch SSTabRestored + // coming from them when creating a multi-window state. + function windowObserver(aSubject, aTopic, aData) { + if (aTopic == "domwindowopened") { + let newWindow = aSubject; + newWindow.addEventListener( + "load", + function() { + if (++windowsOpen == expectedWindows) { + Services.ww.unregisterNotification(windowObserver); + windowObserving = false; + } + + // Track this window so we can remove the progress listener later + windows.push(newWindow); + // Add the progress listener + newWindow.gBrowser.tabContainer.addEventListener( + "SSTabRestored", + onSSTabRestored, + true + ); + }, + { once: true } + ); + } + } + + // We only want to register the notification if we expect more than 1 window + if (expectedWindows > 1) { + registerCleanupFunction(function() { + if (windowObserving) { + Services.ww.unregisterNotification(windowObserver); + } + }); + windowObserving = true; + Services.ww.registerNotification(windowObserver); + } + + registerCleanupFunction(function() { + if (listening) { + windows.forEach(function(win) { + win.gBrowser.tabContainer.removeEventListener( + "SSTabRestored", + onSSTabRestored, + true + ); + }); + } + }); + // Add the event listener for this window as well. + listening = true; + gBrowser.tabContainer.addEventListener( + "SSTabRestored", + onSSTabRestored, + true + ); + + // Ensure setBrowserState() doesn't remove the initial tab. + gBrowser.selectedTab = gBrowser.tabs[0]; + + // Finally, call setBrowserState + ss.setBrowserState(JSON.stringify(aState)); +} + +function promiseBrowserState(aState) { + return new Promise(resolve => waitForBrowserState(aState, resolve)); +} + +function promiseTabState(tab, state) { + if (typeof state != "string") { + state = JSON.stringify(state); + } + + let promise = promiseTabRestored(tab); + ss.setTabState(tab, state); + return promise; +} + +function promiseWindowRestoring(win) { + return new Promise(resolve => + win.addEventListener("SSWindowRestoring", resolve, { once: true }) + ); +} + +function promiseWindowRestored(win) { + return new Promise(resolve => + win.addEventListener("SSWindowRestored", resolve, { once: true }) + ); +} + +async function setBrowserState(state, win = window) { + ss.setBrowserState(typeof state != "string" ? JSON.stringify(state) : state); + await promiseWindowRestored(win); +} + +async function setWindowState(win, state, overwrite = false) { + ss.setWindowState( + win, + typeof state != "string" ? JSON.stringify(state) : state, + overwrite + ); + await promiseWindowRestored(win); +} + +function waitForTopic(aTopic, aTimeout, aCallback) { + let observing = false; + function removeObserver() { + if (!observing) { + return; + } + Services.obs.removeObserver(observer, aTopic); + observing = false; + } + + let timeout = setTimeout(function() { + removeObserver(); + aCallback(false); + }, aTimeout); + + function observer(subject, topic, data) { + removeObserver(); + timeout = clearTimeout(timeout); + executeSoon(() => aCallback(true)); + } + + registerCleanupFunction(function() { + removeObserver(); + if (timeout) { + clearTimeout(timeout); + } + }); + + observing = true; + Services.obs.addObserver(observer, aTopic); +} + +/** + * Wait until session restore has finished collecting its data and is + * has written that data ("sessionstore-state-write-complete"). + * + * @param {function} aCallback If sessionstore-state-write-complete is sent + * within buffering interval + 100 ms, the callback is passed |true|, + * otherwise, it is passed |false|. + */ +function waitForSaveState(aCallback) { + let timeout = + 100 + Services.prefs.getIntPref("browser.sessionstore.interval"); + return waitForTopic("sessionstore-state-write-complete", timeout, aCallback); +} +function promiseSaveState() { + return new Promise((resolve, reject) => { + waitForSaveState(isSuccessful => { + if (!isSuccessful) { + reject(new Error("Save state timeout")); + } else { + resolve(); + } + }); + }); +} +function forceSaveState() { + return SessionSaver.run(); +} + +function promiseRecoveryFileContents() { + let promise = forceSaveState(); + return promise.then(function() { + return IOUtils.readUTF8(SessionFile.Paths.recovery, { + decompress: true, + }); + }); +} + +var promiseForEachSessionRestoreFile = async function(cb) { + for (let key of SessionFile.Paths.loadOrder) { + let data = ""; + try { + data = await IOUtils.readUTF8(SessionFile.Paths[key], { + decompress: true, + }); + } catch (ex) { + // Ignore missing files + if (!(DOMException.isInstance(ex) && ex.name == "NotFoundError")) { + throw ex; + } + } + cb(data, key); + } +}; + +function promiseBrowserLoaded( + aBrowser, + ignoreSubFrames = true, + wantLoad = null +) { + return BrowserTestUtils.browserLoaded(aBrowser, !ignoreSubFrames, wantLoad); +} + +function whenWindowLoaded(aWindow, aCallback) { + aWindow.addEventListener( + "load", + function() { + executeSoon(function executeWhenWindowLoaded() { + aCallback(aWindow); + }); + }, + { once: true } + ); +} +function promiseWindowLoaded(aWindow) { + return new Promise(resolve => whenWindowLoaded(aWindow, resolve)); +} + +var gUniqueCounter = 0; +function r() { + return Date.now() + "-" + ++gUniqueCounter; +} + +function* BrowserWindowIterator() { + for (let currentWindow of Services.wm.getEnumerator("navigator:browser")) { + if (!currentWindow.closed) { + yield currentWindow; + } + } +} + +var gWebProgressListener = { + _callback: null, + + setCallback(aCallback) { + if (!this._callback) { + window.gBrowser.addTabsProgressListener(this); + } + this._callback = aCallback; + }, + + unsetCallback() { + if (this._callback) { + this._callback = null; + window.gBrowser.removeTabsProgressListener(this); + } + }, + + onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) { + if ( + aStateFlags & Ci.nsIWebProgressListener.STATE_STOP && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK && + aStateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW + ) { + this._callback(aBrowser); + } + }, +}; + +registerCleanupFunction(function() { + gWebProgressListener.unsetCallback(); +}); + +var gProgressListener = { + _callback: null, + + setCallback(callback) { + Services.obs.addObserver(this, "sessionstore-debug-tab-restored"); + this._callback = callback; + }, + + unsetCallback() { + if (this._callback) { + this._callback = null; + Services.obs.removeObserver(this, "sessionstore-debug-tab-restored"); + } + }, + + observe(browser, topic, data) { + gProgressListener.onRestored(browser); + }, + + onRestored(browser) { + if (ss.getInternalObjectState(browser) == TAB_STATE_RESTORING) { + let args = [browser].concat(gProgressListener._countTabs()); + gProgressListener._callback.apply(gProgressListener, args); + } + }, + + _countTabs() { + let needsRestore = 0, + isRestoring = 0, + wasRestored = 0; + + for (let win of BrowserWindowIterator()) { + for (let i = 0; i < win.gBrowser.tabs.length; i++) { + let browser = win.gBrowser.tabs[i].linkedBrowser; + let state = ss.getInternalObjectState(browser); + if (browser.isConnected && !state) { + wasRestored++; + } else if (state == TAB_STATE_RESTORING) { + isRestoring++; + } else if (state == TAB_STATE_NEEDS_RESTORE || !browser.isConnected) { + needsRestore++; + } + } + } + return [needsRestore, isRestoring, wasRestored]; + }, +}; + +registerCleanupFunction(function() { + gProgressListener.unsetCallback(); +}); + +// Close all but our primary window. +function promiseAllButPrimaryWindowClosed() { + let windows = []; + for (let win of BrowserWindowIterator()) { + if (win != window) { + windows.push(win); + } + } + + return Promise.all(windows.map(BrowserTestUtils.closeWindow)); +} + +// Forget all closed windows. +function forgetClosedWindows() { + while (ss.getClosedWindowCount() > 0) { + ss.forgetClosedWindow(0); + } +} + +/** + * When opening a new window it is not sufficient to wait for its load event. + * We need to use whenDelayedStartupFinshed() here as the browser window's + * delayedStartup() routine is executed one tick after the window's load event + * has been dispatched. browser-delayed-startup-finished might be deferred even + * further if parts of the window's initialization process take more time than + * expected (e.g. reading a big session state from disk). + */ +function whenNewWindowLoaded(aOptions, aCallback) { + let features = ""; + let url = "about:blank"; + + if ((aOptions && aOptions.private) || false) { + features = ",private"; + url = "about:privatebrowsing"; + } + + let win = openDialog( + AppConstants.BROWSER_CHROME_URL, + "", + "chrome,all,dialog=no" + features, + url + ); + let delayedStartup = promiseDelayedStartupFinished(win); + + let browserLoaded = new Promise(resolve => { + if (url == "about:blank") { + resolve(); + return; + } + + win.addEventListener( + "load", + function() { + let browser = win.gBrowser.selectedBrowser; + promiseBrowserLoaded(browser).then(resolve); + }, + { once: true } + ); + }); + + Promise.all([delayedStartup, browserLoaded]).then(() => aCallback(win)); +} +function promiseNewWindowLoaded(aOptions) { + return new Promise(resolve => whenNewWindowLoaded(aOptions, resolve)); +} + +/** + * This waits for the browser-delayed-startup-finished notification of a given + * window. It indicates that the windows has loaded completely and is ready to + * be used for testing. + */ +function whenDelayedStartupFinished(aWindow, aCallback) { + Services.obs.addObserver(function observer(aSubject, aTopic) { + if (aWindow == aSubject) { + Services.obs.removeObserver(observer, aTopic); + executeSoon(aCallback); + } + }, "browser-delayed-startup-finished"); +} +function promiseDelayedStartupFinished(aWindow) { + return new Promise(resolve => whenDelayedStartupFinished(aWindow, resolve)); +} + +function promiseTabRestored(tab) { + return BrowserTestUtils.waitForEvent(tab, "SSTabRestored"); +} + +function promiseTabRestoring(tab) { + return BrowserTestUtils.waitForEvent(tab, "SSTabRestoring"); +} + +// Removes the given tab immediately and returns a promise that resolves when +// all pending status updates (messages) of the closing tab have been received. +function promiseRemoveTabAndSessionState(tab) { + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + return sessionUpdatePromise; +} + +// Write DOMSessionStorage data to the given browser. +function modifySessionStorage(browser, storageData, storageOptions = {}) { + let browsingContext = browser.browsingContext; + if (storageOptions && "frameIndex" in storageOptions) { + browsingContext = browsingContext.children[storageOptions.frameIndex]; + } + + return SpecialPowers.spawn( + browsingContext, + [[storageData, storageOptions]], + async function([data, options]) { + let frame = content; + let keys = new Set(Object.keys(data)); + let isClearing = !keys.size; + let storage = frame.sessionStorage; + + return new Promise(resolve => { + docShell.chromeEventHandler.addEventListener( + "MozSessionStorageChanged", + function onStorageChanged(event) { + if (event.storageArea == storage) { + keys.delete(event.key); + } + + if (keys.size == 0) { + docShell.chromeEventHandler.removeEventListener( + "MozSessionStorageChanged", + onStorageChanged, + true + ); + resolve(); + } + }, + true + ); + + if (isClearing) { + storage.clear(); + } else { + for (let key of keys) { + frame.sessionStorage[key] = data[key]; + } + } + }); + } + ); +} + +function pushPrefs(...aPrefs) { + return SpecialPowers.pushPrefEnv({ set: aPrefs }); +} + +function popPrefs() { + return SpecialPowers.popPrefEnv(); +} + +function setScrollPosition(bc, x, y) { + return SpecialPowers.spawn(bc, [x, y], (childX, childY) => { + return new Promise(resolve => { + content.addEventListener( + "mozvisualscroll", + function onScroll(event) { + if (content.document.ownerGlobal.visualViewport == event.target) { + content.removeEventListener("mozvisualscroll", onScroll, { + mozSystemGroup: true, + }); + resolve(); + } + }, + { mozSystemGroup: true } + ); + content.scrollTo(childX, childY); + }); + }); +} + +async function checkScroll(tab, expected, msg) { + let browser = tab.linkedBrowser; + await TabStateFlusher.flush(browser); + + let scroll = JSON.parse(ss.getTabState(tab)).scroll || null; + is(JSON.stringify(scroll), JSON.stringify(expected), msg); +} + +function whenDomWindowClosedHandled(aCallback) { + Services.obs.addObserver(function observer(aSubject, aTopic) { + Services.obs.removeObserver(observer, aTopic); + aCallback(); + }, "sessionstore-debug-domwindowclosed-handled"); +} + +function getPropertyOfFormField(browserContext, selector, propName) { + return SpecialPowers.spawn( + browserContext, + [selector, propName], + (selectorChild, propNameChild) => { + return content.document.querySelector(selectorChild)[propNameChild]; + } + ); +} + +function setPropertyOfFormField(browserContext, selector, propName, newValue) { + return SpecialPowers.spawn( + browserContext, + [selector, propName, newValue], + (selectorChild, propNameChild, newValueChild) => { + let node = content.document.querySelector(selectorChild); + node[propNameChild] = newValueChild; + + let event = node.ownerDocument.createEvent("UIEvents"); + event.initUIEvent("input", true, true, node.ownerGlobal, 0); + node.dispatchEvent(event); + } + ); +} + +function promiseOnHistoryReplaceEntry(browser) { + if (SpecialPowers.Services.appinfo.sessionHistoryInParent) { + return new Promise(resolve => { + let sessionHistory = browser.browsingContext?.sessionHistory; + if (sessionHistory) { + var historyListener = { + OnHistoryNewEntry() {}, + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReload() { + return true; + }, + + OnHistoryReplaceEntry() { + resolve(); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]), + }; + + sessionHistory.addSHistoryListener(historyListener); + } + }); + } + + return SpecialPowers.spawn(browser, [], () => { + return new Promise(resolve => { + var historyListener = { + OnHistoryNewEntry() {}, + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReload() { + return true; + }, + + OnHistoryReplaceEntry() { + resolve(); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]), + }; + + var { sessionHistory } = this.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + if (sessionHistory) { + sessionHistory.legacySHistory.addSHistoryListener(historyListener); + } + }); + }); +} + +function loadTestSubscript(filePath) { + Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this); +} + +function addCoopTask(aFile, aTest, aUrlRoot) { + async function taskToBeAdded() { + info(`File ${aFile} has COOP headers enabled`); + let filePath = `browser/browser/components/sessionstore/test/${aFile}`; + let url = aUrlRoot + `coopHeaderCommon.sjs?fileRoot=${filePath}`; + await aTest(url); + } + Object.defineProperty(taskToBeAdded, "name", { value: aTest.name }); + add_task(taskToBeAdded); +} + +function addNonCoopTask(aFile, aTest, aUrlRoot) { + async function taskToBeAdded() { + await aTest(aUrlRoot + aFile); + } + Object.defineProperty(taskToBeAdded, "name", { value: aTest.name }); + add_task(taskToBeAdded); +} + +async function openAndCloseTab(window, url) { + let tab = BrowserTestUtils.addTab(window.gBrowser, url); + await promiseBrowserLoaded(tab.linkedBrowser, true, url); + await TabStateFlusher.flush(tab.linkedBrowser); + await promiseRemoveTabAndSessionState(tab); +} diff --git a/browser/components/sessionstore/test/marionette/manifest.ini b/browser/components/sessionstore/test/marionette/manifest.ini new file mode 100644 index 0000000000..f082219465 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/manifest.ini @@ -0,0 +1,12 @@ +[DEFAULT] +tags = local + +[test_restore_windows_after_restart_and_quit.py] +[test_restore_windows_after_windows_shutdown.py] +skip-if = + os != "win" + win10_2004 # Bug 1727691 + win11_2009 # Bug 1727691 +[test_restore_windows_after_close_last_tabs.py] +skip-if = + os == "mac" diff --git a/browser/components/sessionstore/test/marionette/session_store_test_case.py b/browser/components/sessionstore/test/marionette/session_store_test_case.py new file mode 100644 index 0000000000..315adc5c47 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/session_store_test_case.py @@ -0,0 +1,427 @@ +# 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/. + +from urllib.parse import quote + +from marionette_driver import Wait, errors +from marionette_driver.keys import Keys +from marionette_harness import MarionetteTestCase, WindowManagerMixin + + +def inline(doc): + return "data:text/html;charset=utf-8,{}".format(quote(doc)) + + +class SessionStoreTestCase(WindowManagerMixin, MarionetteTestCase): + def setUp( + self, + startup_page=1, + include_private=True, + no_auto_updates=True, + win_register_restart=False, + ): + super(SessionStoreTestCase, self).setUp() + self.marionette.set_context("chrome") + + platform = self.marionette.session_capabilities["platformName"] + self.accelKey = Keys.META if platform == "mac" else Keys.CONTROL + + # Each list element represents a window of tabs loaded at + # some testing URL + self.test_windows = set( + [ + # Window 1. Note the comma after the inline call - + # this is Python's way of declaring a 1 item tuple. + (inline("""<div">Lorem</div>"""),), + # Window 2 + ( + inline("""<div">ipsum</div>"""), + inline("""<div">dolor</div>"""), + ), + # Window 3 + ( + inline("""<div">sit</div>"""), + inline("""<div">amet</div>"""), + ), + ] + ) + + self.private_windows = set( + [ + ( + inline("""<div">consectetur</div>"""), + inline("""<div">ipsum</div>"""), + ), + ( + inline("""<div">adipiscing</div>"""), + inline("""<div">consectetur</div>"""), + ), + ] + ) + + self.marionette.enforce_gecko_prefs( + { + # Set browser restore previous session pref, + # depending on what the test requires. + "browser.startup.page": startup_page, + # Make the content load right away instead of waiting for + # the user to click on the background tabs + "browser.sessionstore.restore_on_demand": False, + # Avoid race conditions by having the content process never + # send us session updates unless the parent has explicitly asked + # for them via the TabStateFlusher. + "browser.sessionstore.debug.no_auto_updates": no_auto_updates, + # Whether to enable the register application restart mechanism. + "toolkit.winRegisterApplicationRestart": win_register_restart, + } + ) + + self.all_windows = self.test_windows.copy() + self.open_windows(self.test_windows) + + if include_private: + self.all_windows.update(self.private_windows) + self.open_windows(self.private_windows, is_private=True) + + def tearDown(self): + try: + # Create a fresh profile for subsequent tests. + self.marionette.restart(in_app=False, clean=True) + finally: + super(SessionStoreTestCase, self).tearDown() + + def open_windows(self, window_sets, is_private=False): + """Open a set of windows with tabs pointing at some URLs. + + @param window_sets (list) + A set of URL tuples. Each tuple within window_sets + represents a window, and each URL in the URL + tuples represents what will be loaded in a tab. + + Note that if is_private is False, then the first + URL tuple will be opened in the current window, and + subequent tuples will be opened in new windows. + + Example: + + set( + (self.marionette.absolute_url('layout/mozilla_1.html'), + self.marionette.absolute_url('layout/mozilla_2.html')), + + (self.marionette.absolute_url('layout/mozilla_3.html'), + self.marionette.absolute_url('layout/mozilla_4.html')), + ) + + This would take the currently open window, and load + mozilla_1.html and mozilla_2.html in new tabs. It would + then open a new, second window, and load tabs at + mozilla_3.html and mozilla_4.html. + @param is_private (boolean, optional) + Whether or not any new windows should be a private browsing + windows. + """ + if is_private: + win = self.open_window(private=True) + self.marionette.switch_to_window(win) + else: + win = self.marionette.current_chrome_window_handle + + for index, urls in enumerate(window_sets): + if index > 0: + win = self.open_window(private=is_private) + self.marionette.switch_to_window(win) + self.open_tabs(win, urls) + + def open_tabs(self, win, urls): + """Open a set of URLs inside a window in new tabs. + + @param win (browser window) + The browser window to load the tabs in. + @param urls (tuple) + A tuple of URLs to load in this window. The + first URL will be loaded in the currently selected + browser tab. Subsequent URLs will be loaded in + new tabs. + """ + # If there are any remaining URLs for this window, + # open some new tabs and navigate to them. + with self.marionette.using_context("content"): + if isinstance(urls, str): + self.marionette.navigate(urls) + else: + for index, url in enumerate(urls): + if index > 0: + tab = self.open_tab() + self.marionette.switch_to_window(tab) + self.marionette.navigate(url) + + def wait_for_windows(self, expected_windows, message, timeout=5): + current_windows = None + + def check(_): + nonlocal current_windows + current_windows = self.convert_open_windows_to_set() + return current_windows == expected_windows + + try: + wait = Wait(self.marionette, timeout=timeout, interval=0.1) + wait.until(check, message=message) + except errors.TimeoutException as e: + # Update the message to include the most recent list of windows + message = ( + f"{e.message}. Expected {expected_windows}, got {current_windows}." + ) + raise errors.TimeoutException(message) + + def get_urls_for_window(self, win): + orig_handle = self.marionette.current_chrome_window_handle + + try: + with self.marionette.using_context("chrome"): + self.marionette.switch_to_window(win) + return self.marionette.execute_script( + """ + return gBrowser.tabs.map(tab => { + return tab.linkedBrowser.currentURI.spec; + }); + """ + ) + finally: + self.marionette.switch_to_window(orig_handle) + + def convert_open_windows_to_set(self): + # There's no guarantee that Marionette will return us an + # iterator for the opened windows that will match the + # order within our window list. Instead, we'll convert + # the list of URLs within each open window to a set of + # tuples that will allow us to do a direct comparison + # while allowing the windows to be in any order. + opened_windows = set() + for win in self.marionette.chrome_window_handles: + urls = tuple(self.get_urls_for_window(win)) + opened_windows.add(urls) + + return opened_windows + + def _close_tab_shortcut(self): + self.marionette.actions.sequence("key", "keyboard_id").key_down( + self.accelKey + ).key_down("w").key_up("w").key_up(self.accelKey).perform() + + def close_all_tabs_and_restart(self): + self.close_all_tabs() + self.marionette.quit(callback=self._close_tab_shortcut) + self.marionette.start_session() + + def simulate_os_shutdown(self): + """Simulate an OS shutdown. + + :raises: Exception: if not supported on the current platform + :raises: WindowsError: if a Windows API call failed + """ + if self.marionette.session_capabilities["platformName"] != "windows": + raise Exception("Unsupported platform for simulate_os_shutdown") + + self._shutdown_with_windows_restart_manager(self.marionette.process_id) + + def _shutdown_with_windows_restart_manager(self, pid): + """Shut down a process using the Windows Restart Manager. + + When Windows shuts down, it uses a protocol including the + WM_QUERYENDSESSION and WM_ENDSESSION messages to give + applications a chance to shut down safely. The best way to + simulate this is via the Restart Manager, which allows a process + (such as an installer) to use the same mechanism to shut down + any other processes which are using registered resources. + + This function starts a Restart Manager session, registers the + process as a resource, and shuts down the process. + + :param pid: The process id (int) of the process to shutdown + + :raises: WindowsError: if a Windows API call fails + """ + import ctypes + from ctypes import POINTER, WINFUNCTYPE, Structure, WinError, pointer, windll + from ctypes.wintypes import BOOL, DWORD, HANDLE, LPCWSTR, UINT, ULONG, WCHAR + + # set up Windows SDK types + OpenProcess = windll.kernel32.OpenProcess + OpenProcess.restype = HANDLE + OpenProcess.argtypes = [ + DWORD, # dwDesiredAccess + BOOL, # bInheritHandle + DWORD, + ] # dwProcessId + PROCESS_QUERY_INFORMATION = 0x0400 + + class FILETIME(Structure): + _fields_ = [("dwLowDateTime", DWORD), ("dwHighDateTime", DWORD)] + + LPFILETIME = POINTER(FILETIME) + + GetProcessTimes = windll.kernel32.GetProcessTimes + GetProcessTimes.restype = BOOL + GetProcessTimes.argtypes = [ + HANDLE, # hProcess + LPFILETIME, # lpCreationTime + LPFILETIME, # lpExitTime + LPFILETIME, # lpKernelTime + LPFILETIME, + ] # lpUserTime + + ERROR_SUCCESS = 0 + + class RM_UNIQUE_PROCESS(Structure): + _fields_ = [("dwProcessId", DWORD), ("ProcessStartTime", FILETIME)] + + RmStartSession = windll.rstrtmgr.RmStartSession + RmStartSession.restype = DWORD + RmStartSession.argtypes = [ + POINTER(DWORD), # pSessionHandle + DWORD, # dwSessionFlags + POINTER(WCHAR), + ] # strSessionKey + + class GUID(ctypes.Structure): + _fields_ = [ + ("Data1", ctypes.c_ulong), + ("Data2", ctypes.c_ushort), + ("Data3", ctypes.c_ushort), + ("Data4", ctypes.c_ubyte * 8), + ] + + CCH_RM_SESSION_KEY = ctypes.sizeof(GUID) * 2 + + RmRegisterResources = windll.rstrtmgr.RmRegisterResources + RmRegisterResources.restype = DWORD + RmRegisterResources.argtypes = [ + DWORD, # dwSessionHandle + UINT, # nFiles + POINTER(LPCWSTR), # rgsFilenames + UINT, # nApplications + POINTER(RM_UNIQUE_PROCESS), # rgApplications + UINT, # nServices + POINTER(LPCWSTR), + ] # rgsServiceNames + + RM_WRITE_STATUS_CALLBACK = WINFUNCTYPE(None, UINT) + RmShutdown = windll.rstrtmgr.RmShutdown + RmShutdown.restype = DWORD + RmShutdown.argtypes = [ + DWORD, # dwSessionHandle + ULONG, # lActionFlags + RM_WRITE_STATUS_CALLBACK, + ] # fnStatus + + RmEndSession = windll.rstrtmgr.RmEndSession + RmEndSession.restype = DWORD + RmEndSession.argtypes = [DWORD] # dwSessionHandle + + # Get the info needed to uniquely identify the process + hProc = OpenProcess(PROCESS_QUERY_INFORMATION, False, pid) + if not hProc: + raise WinError() + + creationTime = FILETIME() + exitTime = FILETIME() + kernelTime = FILETIME() + userTime = FILETIME() + if not GetProcessTimes( + hProc, + pointer(creationTime), + pointer(exitTime), + pointer(kernelTime), + pointer(userTime), + ): + raise WinError() + + # Start the Restart Manager Session + dwSessionHandle = DWORD() + sessionKeyType = WCHAR * (CCH_RM_SESSION_KEY + 1) + sessionKey = sessionKeyType() + if RmStartSession(pointer(dwSessionHandle), 0, sessionKey) != ERROR_SUCCESS: + raise WinError() + + try: + UProcs_count = 1 + UProcsArrayType = RM_UNIQUE_PROCESS * UProcs_count + UProcs = UProcsArrayType(RM_UNIQUE_PROCESS(pid, creationTime)) + + # Register the process as a resource + if ( + RmRegisterResources( + dwSessionHandle, 0, None, UProcs_count, UProcs, 0, None + ) + != ERROR_SUCCESS + ): + raise WinError() + + # Shut down all processes using registered resources + if ( + RmShutdown( + dwSessionHandle, 0, ctypes.cast(None, RM_WRITE_STATUS_CALLBACK) + ) + != ERROR_SUCCESS + ): + raise WinError() + + finally: + RmEndSession(dwSessionHandle) + + def windows_shutdown_with_variety(self, restart_by_os, expect_restore): + """Test restoring windows after Windows shutdown. + + Opens a set of windows, both standard and private, with + some number of tabs in them. Once the tabs have loaded, shuts down + the browser with the Windows Restart Manager and restarts the browser. + + This specifically exercises the Windows synchronous shutdown mechanism, + which terminates the process in response to the Restart Manager's + WM_ENDSESSION message. + + If restart_by_os is True, the -os-restarted arg is passed when restarting, + simulating being automatically restarted by the Restart Manager. + + If expect_restore is True, this ensures that the standard tabs have been + restored, and that the private ones have not. Otherwise it ensures that + no tabs and windows have been restored. + """ + current_windows_set = self.convert_open_windows_to_set() + self.assertEqual( + current_windows_set, + self.all_windows, + msg="Not all requested windows have been opened. Expected {}, got {}.".format( + self.all_windows, current_windows_set + ), + ) + + self.marionette.quit(callback=lambda: self.simulate_os_shutdown()) + + saved_args = self.marionette.instance.app_args + try: + if restart_by_os: + self.marionette.instance.app_args = ["-os-restarted"] + + self.marionette.start_session() + self.marionette.set_context("chrome") + finally: + self.marionette.instance.app_args = saved_args + + if expect_restore: + self.wait_for_windows( + self.test_windows, + "Non private browsing windows should have been restored", + ) + else: + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Windows from last session shouldn`t have been restored.", + ) + self.assertEqual( + len(self.marionette.window_handles), + 1, + msg="Tabs from last session shouldn`t have been restored.", + ) diff --git a/browser/components/sessionstore/test/marionette/test_restore_windows_after_close_last_tabs.py b/browser/components/sessionstore/test/marionette/test_restore_windows_after_close_last_tabs.py new file mode 100644 index 0000000000..2022d8fb87 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_windows_after_close_last_tabs.py @@ -0,0 +1,59 @@ +# 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/. + +# add this directory to the path +import os +import sys + +sys.path.append(os.path.dirname(__file__)) + +from session_store_test_case import SessionStoreTestCase + + +class TestSessionStoreEnabledAllWindows(SessionStoreTestCase): + def setUp(self, include_private=True): + """Setup for the test, enabling session restore. + + :param include_private: Whether to open private windows. + """ + super(TestSessionStoreEnabledAllWindows, self).setUp( + include_private=include_private, startup_page=3 + ) + + def test_with_variety(self): + """Test opening and restoring both standard and private windows. + + Opens a set of windows, both standard and private, with + some number of tabs in them. Once the tabs have loaded, restarts + the browser, and then ensures that the standard tabs have been + restored, and that the private ones have not. + """ + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.marionette.quit() + self.marionette.start_session() + + self.wait_for_windows( + self.test_windows, "Non private browsing windows should have been restored" + ) + + def test_close_tabs(self): + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.close_all_tabs_and_restart() + + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Windows from last session shouldn`t have been restored.", + ) + self.assertEqual( + len(self.marionette.window_handles), + 1, + msg="Tabs from last session shouldn`t have been restored.", + ) diff --git a/browser/components/sessionstore/test/marionette/test_restore_windows_after_restart_and_quit.py b/browser/components/sessionstore/test/marionette/test_restore_windows_after_restart_and_quit.py new file mode 100644 index 0000000000..be17f08472 --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_windows_after_restart_and_quit.py @@ -0,0 +1,82 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# add this directory to the path +import os +import sys + +sys.path.append(os.path.dirname(__file__)) + +from session_store_test_case import SessionStoreTestCase + + +class TestSessionStoreEnabledAllWindows(SessionStoreTestCase): + def setUp(self, include_private=True): + """Setup for the test, enabling session restore. + + :param include_private: Whether to open private windows. + """ + super(TestSessionStoreEnabledAllWindows, self).setUp( + include_private=include_private, startup_page=3 + ) + + def test_with_variety(self): + """Test opening and restoring both standard and private windows. + + Opens a set of windows, both standard and private, with + some number of tabs in them. Once the tabs have loaded, restarts + the browser, and then ensures that the standard tabs have been + restored, and that the private ones have not. + """ + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.marionette.quit() + self.marionette.start_session() + self.marionette.set_context("chrome") + + self.wait_for_windows( + self.test_windows, "Non private browsing windows should have been restored" + ) + + +class TestSessionStoreEnabledNoPrivateWindows(TestSessionStoreEnabledAllWindows): + def setUp(self): + super(TestSessionStoreEnabledNoPrivateWindows, self).setUp( + include_private=False + ) + + +class TestSessionStoreDisabled(SessionStoreTestCase): + def test_no_restore_with_quit(self): + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.marionette.quit() + self.marionette.start_session() + self.marionette.set_context("chrome") + + self.assertEqual( + len(self.marionette.chrome_window_handles), + 1, + msg="Windows from last session shouldn`t have been restored.", + ) + self.assertEqual( + len(self.marionette.window_handles), + 1, + msg="Tabs from last session shouldn`t have been restored.", + ) + + def test_restore_with_restart(self): + self.wait_for_windows( + self.all_windows, "Not all requested windows have been opened" + ) + + self.marionette.restart(in_app=True) + + self.wait_for_windows( + self.test_windows, "Non private browsing windows should have been restored" + ) diff --git a/browser/components/sessionstore/test/marionette/test_restore_windows_after_windows_shutdown.py b/browser/components/sessionstore/test/marionette/test_restore_windows_after_windows_shutdown.py new file mode 100644 index 0000000000..21eec455bb --- /dev/null +++ b/browser/components/sessionstore/test/marionette/test_restore_windows_after_windows_shutdown.py @@ -0,0 +1,66 @@ +# 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/. + +# add this directory to the path +import os +import sys + +sys.path.append(os.path.dirname(__file__)) + +from session_store_test_case import SessionStoreTestCase + +# We test the following combinations with simulated Windows shutdown: +# - Start page = restore session (expect restore in all cases) +# - RAR (toolkit.winRegisterApplicationRestart) disabled +# - RAR enabled, restarted manually +# +# - Start page = home +# - RAR disabled (no restore) +# - RAR enabled: +# - restarted by OS (restore) +# - restarted manually (no restore) + + +class TestWindowsShutdown(SessionStoreTestCase): + def setUp(self): + super(TestWindowsShutdown, self).setUp(startup_page=3, no_auto_updates=False) + + def test_with_variety(self): + """Test session restore selected by user.""" + self.windows_shutdown_with_variety(restart_by_os=False, expect_restore=True) + + +class TestWindowsShutdownRegisterRestart(SessionStoreTestCase): + def setUp(self): + super(TestWindowsShutdownRegisterRestart, self).setUp( + startup_page=3, no_auto_updates=False, win_register_restart=True + ) + + def test_manual_restart(self): + """Test that restore tabs works in case of register restart failure.""" + self.windows_shutdown_with_variety(restart_by_os=False, expect_restore=True) + + +class TestWindowsShutdownNormal(SessionStoreTestCase): + def setUp(self): + super(TestWindowsShutdownNormal, self).setUp(no_auto_updates=False) + + def test_with_variety(self): + """Test that windows are not restored on a normal restart.""" + self.windows_shutdown_with_variety(restart_by_os=False, expect_restore=False) + + +class TestWindowsShutdownForcedSessionRestore(SessionStoreTestCase): + def setUp(self): + super(TestWindowsShutdownForcedSessionRestore, self).setUp( + no_auto_updates=False, win_register_restart=True + ) + + def test_os_restart(self): + """Test that register application restart restores the session.""" + self.windows_shutdown_with_variety(restart_by_os=True, expect_restore=True) + + def test_manual_restart(self): + """Test that OS shutdown is ignored on manual start.""" + self.windows_shutdown_with_variety(restart_by_os=False, expect_restore=False) diff --git a/browser/components/sessionstore/test/restore_redirect_http.html b/browser/components/sessionstore/test/restore_redirect_http.html new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/browser/components/sessionstore/test/restore_redirect_http.html diff --git a/browser/components/sessionstore/test/restore_redirect_http.html^headers^ b/browser/components/sessionstore/test/restore_redirect_http.html^headers^ new file mode 100644 index 0000000000..533bda36f3 --- /dev/null +++ b/browser/components/sessionstore/test/restore_redirect_http.html^headers^ @@ -0,0 +1,2 @@ +HTTP 302 Moved Temporarily +Location: restore_redirect_target.html diff --git a/browser/components/sessionstore/test/restore_redirect_js.html b/browser/components/sessionstore/test/restore_redirect_js.html new file mode 100644 index 0000000000..f0130847b6 --- /dev/null +++ b/browser/components/sessionstore/test/restore_redirect_js.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> + +<html> +<head> +<script> +var newLocation = window.location.toString().replace("restore_redirect_js.html", "restore_redirect_target.html"); +window.location.replace(newLocation); +</script> +</head> +</html> diff --git a/browser/components/sessionstore/test/restore_redirect_target.html b/browser/components/sessionstore/test/restore_redirect_target.html new file mode 100644 index 0000000000..813af05508 --- /dev/null +++ b/browser/components/sessionstore/test/restore_redirect_target.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<meta charset="utf-8"/> +<html> +<head> +<title>Test page</title> +</head> +<body>Test page</body> +</html> diff --git a/browser/components/sessionstore/test/unit/data/sessionCheckpoints_all.json b/browser/components/sessionstore/test/unit/data/sessionCheckpoints_all.json new file mode 100644 index 0000000000..928de6a39b --- /dev/null +++ b/browser/components/sessionstore/test/unit/data/sessionCheckpoints_all.json @@ -0,0 +1 @@ +{"profile-after-change":true,"final-ui-startup":true,"sessionstore-windows-restored":true,"quit-application-granted":true,"quit-application":true,"sessionstore-final-state-write-complete":true,"profile-change-net-teardown":true,"profile-change-teardown":true,"profile-before-change":true}
\ No newline at end of file diff --git a/browser/components/sessionstore/test/unit/data/sessionstore_invalid.js b/browser/components/sessionstore/test/unit/data/sessionstore_invalid.js new file mode 100644 index 0000000000..a8c3ff2ff9 --- /dev/null +++ b/browser/components/sessionstore/test/unit/data/sessionstore_invalid.js @@ -0,0 +1,3 @@ +{ + "windows": // invalid json +} diff --git a/browser/components/sessionstore/test/unit/data/sessionstore_valid.js b/browser/components/sessionstore/test/unit/data/sessionstore_valid.js new file mode 100644 index 0000000000..f9511f29f6 --- /dev/null +++ b/browser/components/sessionstore/test/unit/data/sessionstore_valid.js @@ -0,0 +1,3 @@ +{ + "windows": [] +}
\ No newline at end of file diff --git a/browser/components/sessionstore/test/unit/head.js b/browser/components/sessionstore/test/unit/head.js new file mode 100644 index 0000000000..7217841201 --- /dev/null +++ b/browser/components/sessionstore/test/unit/head.js @@ -0,0 +1,36 @@ +ChromeUtils.defineESModuleGetters(this, { + SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs", +}); + +// Call a function once initialization of SessionStartup is complete +function afterSessionStartupInitialization(cb) { + info("Waiting for session startup initialization"); + let observer = function() { + try { + info("Session startup initialization observed"); + Services.obs.removeObserver(observer, "sessionstore-state-finalized"); + cb(); + } catch (ex) { + do_throw(ex); + } + }; + Services.obs.addObserver(observer, "sessionstore-state-finalized"); + + // We need the Crash Monitor initialized for sessionstartup to run + // successfully. + const { CrashMonitor } = ChromeUtils.import( + "resource://gre/modules/CrashMonitor.jsm" + ); + CrashMonitor.init(); + + // Start sessionstartup initialization. + SessionStartup.init(); +} + +// Compress the source file using lz4 and put the result to destination file. +// After that, source file is deleted. +async function writeCompressedFile(source, destination) { + let s = await IOUtils.read(source); + await IOUtils.write(destination, s, { compress: true }); + await IOUtils.remove(source); +} diff --git a/browser/components/sessionstore/test/unit/test_backup_once.js b/browser/components/sessionstore/test/unit/test_backup_once.js new file mode 100644 index 0000000000..19bb267ef3 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_backup_once.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SessionWriter } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionWriter.sys.mjs" +); + +// Make sure that we have a profile before initializing SessionFile. +const profd = do_get_profile(); +const { SessionFile } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); +const Paths = SessionFile.Paths; + +// We need a XULAppInfo to initialize SessionFile +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo({ + name: "SessionRestoreTest", + ID: "{230de50e-4cd1-11dc-8314-0800200c9a66}", + version: "1", + platformVersion: "", +}); + +add_setup(async function() { + let source = do_get_file("data/sessionstore_valid.js"); + source.copyTo(profd, "sessionstore.js"); + await writeCompressedFile(Paths.clean.replace("jsonlz4", "js"), Paths.clean); + + // Finish initialization of SessionFile + await SessionFile.read(); +}); + +function promise_check_exist(path, shouldExist) { + return (async function() { + info( + "Ensuring that " + path + (shouldExist ? " exists" : " does not exist") + ); + if ((await IOUtils.exists(path)) != shouldExist) { + throw new Error( + "File" + path + " should " + (shouldExist ? "exist" : "not exist") + ); + } + })(); +} + +function promise_check_contents(path, expect) { + return (async function() { + info("Checking whether " + path + " has the right contents"); + let actual = await IOUtils.readJSON(path, { + decompress: true, + }); + Assert.deepEqual( + actual, + expect, + `File ${path} contains the expected data.` + ); + })(); +} + +function generateFileContents(id) { + let url = `http://example.com/test_backup_once#${id}_${Math.random()}`; + return { windows: [{ tabs: [{ entries: [{ url }], index: 1 }] }] }; +} + +// Write to the store, and check that it creates: +// - $Path.recovery with the new data +// - $Path.nextUpgradeBackup with the old data +add_task(async function test_first_write_backup() { + let initial_content = generateFileContents("initial"); + let new_content = generateFileContents("test_1"); + + info("Before the first write, none of the files should exist"); + await promise_check_exist(Paths.backups, false); + + await IOUtils.makeDirectory(Paths.backups); + await IOUtils.writeJSON(Paths.clean, initial_content, { + compress: true, + }); + await SessionFile.write(new_content); + + info("After first write, a few files should have been created"); + await promise_check_exist(Paths.backups, true); + await promise_check_exist(Paths.clean, false); + await promise_check_exist(Paths.cleanBackup, true); + await promise_check_exist(Paths.recovery, true); + await promise_check_exist(Paths.recoveryBackup, false); + await promise_check_exist(Paths.nextUpgradeBackup, true); + + await promise_check_contents(Paths.recovery, new_content); + await promise_check_contents(Paths.nextUpgradeBackup, initial_content); +}); + +// Write to the store again, and check that +// - $Path.clean is not written +// - $Path.recovery contains the new data +// - $Path.recoveryBackup contains the previous data +add_task(async function test_second_write_no_backup() { + let new_content = generateFileContents("test_2"); + let previous_backup_content = await IOUtils.readJSON(Paths.recovery, { + decompress: true, + }); + + await IOUtils.remove(Paths.cleanBackup); + + await SessionFile.write(new_content); + + await promise_check_exist(Paths.backups, true); + await promise_check_exist(Paths.clean, false); + await promise_check_exist(Paths.cleanBackup, false); + await promise_check_exist(Paths.recovery, true); + await promise_check_exist(Paths.nextUpgradeBackup, true); + + await promise_check_contents(Paths.recovery, new_content); + await promise_check_contents(Paths.recoveryBackup, previous_backup_content); +}); + +// Make sure that we create $Paths.clean and remove $Paths.recovery* +// upon shutdown +add_task(async function test_shutdown() { + let output = generateFileContents("test_3"); + + await IOUtils.writeUTF8(Paths.recovery, "I should disappear"); + await IOUtils.writeUTF8(Paths.recoveryBackup, "I should also disappear"); + + await SessionWriter.write(output, { + isFinalWrite: true, + performShutdownCleanup: true, + }); + + Assert.ok(!(await IOUtils.exists(Paths.recovery))); + Assert.ok(!(await IOUtils.exists(Paths.recoveryBackup))); + await promise_check_contents(Paths.clean, output); +}); diff --git a/browser/components/sessionstore/test/unit/test_final_write_cleanup.js b/browser/components/sessionstore/test/unit/test_final_write_cleanup.js new file mode 100644 index 0000000000..5a55f274f5 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_final_write_cleanup.js @@ -0,0 +1,118 @@ +"use strict"; + +/** + * This test ensures that we correctly clean up the session state when + * writing with isFinalWrite, which is used on shutdown. It tests that each + * tab's shistory is capped to a maximum number of preceding and succeeding + * entries. + */ + +const { SessionWriter } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionWriter.sys.mjs" +); + +// Make sure that we have a profile before initializing SessionFile. +do_get_profile(); +const { + SessionFile: { Paths }, +} = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); + +const MAX_ENTRIES = 9; +const URL = "http://example.com/#"; + +Cu.importGlobalProperties(["structuredClone"]); + +async function prepareWithLimit(back, fwd) { + SessionWriter.init("empty", false, Paths, { + maxSerializeBack: back, + maxSerializeForward: fwd, + maxUpgradeBackups: 3, + }); + await SessionWriter.wipe(); +} + +add_setup(async function() { + registerCleanupFunction(() => SessionWriter.wipe()); +}); + +function createSessionState(index) { + // Generate the tab state entries and set the one-based + // tab-state index to the middle session history entry. + let tabState = { entries: [], index }; + for (let i = 0; i < MAX_ENTRIES; i++) { + tabState.entries.push({ url: URL + i }); + } + + return { windows: [{ tabs: [tabState] }] }; +} + +async function writeAndParse(state, path, options = {}) { + // We clone here because `write` can change the data passed. + let data = structuredClone(state); + await SessionWriter.write(data, options); + return IOUtils.readJSON(path, { decompress: true }); +} + +add_task(async function test_shistory_cap_none() { + let state = createSessionState(5); + + // Don't limit the number of shistory entries. + await prepareWithLimit(-1, -1); + + // Check that no caps are applied. + let diskState = await writeAndParse(state, Paths.clean, { + isFinalWrite: true, + }); + Assert.deepEqual(state, diskState, "no cap applied"); +}); + +add_task(async function test_shistory_cap_middle() { + let state = createSessionState(5); + await prepareWithLimit(2, 3); + + // Cap is only applied on clean shutdown. + let diskState = await writeAndParse(state, Paths.recovery); + Assert.deepEqual(state, diskState, "no cap applied"); + + // Check that the right number of shistory entries was discarded + // and the shistory index updated accordingly. + diskState = await writeAndParse(state, Paths.clean, { isFinalWrite: true }); + let tabState = state.windows[0].tabs[0]; + tabState.entries = tabState.entries.slice(2, 8); + tabState.index = 3; + Assert.deepEqual(state, diskState, "cap applied"); +}); + +add_task(async function test_shistory_cap_lower_bound() { + let state = createSessionState(1); + await prepareWithLimit(5, 5); + + // Cap is only applied on clean shutdown. + let diskState = await writeAndParse(state, Paths.recovery); + Assert.deepEqual(state, diskState, "no cap applied"); + + // Check that the right number of shistory entries was discarded. + diskState = await writeAndParse(state, Paths.clean, { isFinalWrite: true }); + let tabState = state.windows[0].tabs[0]; + tabState.entries = tabState.entries.slice(0, 6); + Assert.deepEqual(state, diskState, "cap applied"); +}); + +add_task(async function test_shistory_cap_upper_bound() { + let state = createSessionState(MAX_ENTRIES); + await prepareWithLimit(5, 5); + + // Cap is only applied on clean shutdown. + let diskState = await writeAndParse(state, Paths.recovery); + Assert.deepEqual(state, diskState, "no cap applied"); + + // Check that the right number of shistory entries was discarded + // and the shistory index updated accordingly. + diskState = await writeAndParse(state, Paths.clean, { isFinalWrite: true }); + let tabState = state.windows[0].tabs[0]; + tabState.entries = tabState.entries.slice(3); + tabState.index = 6; + Assert.deepEqual(state, diskState, "cap applied"); +}); diff --git a/browser/components/sessionstore/test/unit/test_histogram_corrupt_files.js b/browser/components/sessionstore/test/unit/test_histogram_corrupt_files.js new file mode 100644 index 0000000000..5cdb8aff9f --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_histogram_corrupt_files.js @@ -0,0 +1,117 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * The primary purpose of this test is to ensure that + * the sessionstore component records information about + * corrupted backup files into a histogram. + */ + +"use strict"; + +const Telemetry = Services.telemetry; +const HistogramId = "FX_SESSION_RESTORE_ALL_FILES_CORRUPT"; + +// Prepare the session file. +do_get_profile(); +const { SessionFile } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); + +/** + * A utility function for resetting the histogram and the contents + * of the backup directory. This will also compress the file using lz4 compression. + */ +function promise_reset_session(backups = {}) { + return (async function() { + // Reset the histogram. + Telemetry.getHistogramById(HistogramId).clear(); + + // Reset the contents of the backups directory + await IOUtils.makeDirectory(SessionFile.Paths.backups); + let basePath = do_get_cwd().path; + for (let key of SessionFile.Paths.loadOrder) { + if (backups.hasOwnProperty(key)) { + let path = backups[key]; + const fullPath = PathUtils.join(basePath, ...path); + let s = await IOUtils.read(fullPath); + await IOUtils.write(SessionFile.Paths[key], s, { + compress: true, + }); + } else { + await IOUtils.remove(SessionFile.Paths[key]); + } + } + })(); +} + +/** + * In order to use FX_SESSION_RESTORE_ALL_FILES_CORRUPT histogram + * it has to be registered in "toolkit/components/telemetry/Histograms.json". + * This test ensures that the histogram is registered and empty. + */ +add_task(async function test_ensure_histogram_exists_and_empty() { + let s = Telemetry.getHistogramById(HistogramId).snapshot(); + Assert.equal(s.sum, 0, "Initially, the sum of probes is 0"); +}); + +/** + * Makes sure that the histogram is negatively updated when no + * backup files are present. + */ +add_task(async function test_no_files_exist() { + // No session files are available to SessionFile. + await promise_reset_session(); + + await SessionFile.read(); + // Checking if the histogram is updated negatively + let h = Telemetry.getHistogramById(HistogramId); + let s = h.snapshot(); + Assert.equal(s.values[0], 1, "One probe for the 'false' bucket."); + Assert.equal(s.values[1], 0, "No probes in the 'true' bucket."); +}); + +/** + * Makes sure that the histogram is negatively updated when at least one + * backup file is not corrupted. + */ +add_task(async function test_one_file_valid() { + // Corrupting some backup files. + let invalidSession = ["data", "sessionstore_invalid.js"]; + let validSession = ["data", "sessionstore_valid.js"]; + await promise_reset_session({ + clean: invalidSession, + cleanBackup: validSession, + recovery: invalidSession, + recoveryBackup: invalidSession, + }); + + await SessionFile.read(); + // Checking if the histogram is updated negatively. + let h = Telemetry.getHistogramById(HistogramId); + let s = h.snapshot(); + Assert.equal(s.values[0], 1, "One probe for the 'false' bucket."); + Assert.equal(s.values[1], 0, "No probes in the 'true' bucket."); +}); + +/** + * Makes sure that the histogram is positively updated when all + * backup files are corrupted. + */ +add_task(async function test_all_files_corrupt() { + // Corrupting all backup files. + let invalidSession = ["data", "sessionstore_invalid.js"]; + await promise_reset_session({ + clean: invalidSession, + cleanBackup: invalidSession, + recovery: invalidSession, + recoveryBackup: invalidSession, + }); + + await SessionFile.read(); + // Checking if the histogram is positively updated. + let h = Telemetry.getHistogramById(HistogramId); + let s = h.snapshot(); + Assert.equal(s.values[1], 1, "One probe for the 'true' bucket."); + Assert.equal(s.values[0], 0, "No probes in the 'false' bucket."); +}); diff --git a/browser/components/sessionstore/test/unit/test_migration_lz4compression.js b/browser/components/sessionstore/test/unit/test_migration_lz4compression.js new file mode 100644 index 0000000000..7fea3ece20 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_migration_lz4compression.js @@ -0,0 +1,151 @@ +"use strict"; + +const { SessionWriter } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionWriter.sys.mjs" +); + +// Make sure that we have a profile before initializing SessionFile. +const profd = do_get_profile(); +const { SessionFile } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" +); +const Paths = SessionFile.Paths; + +// We need a XULAppInfo to initialize SessionFile +const { updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo({ + name: "SessionRestoreTest", + ID: "{230de50e-4cd1-11dc-8314-0800200c9a66}", + version: "1", + platformVersion: "", +}); + +function promise_check_exist(path, shouldExist) { + return (async function() { + info( + "Ensuring that " + path + (shouldExist ? " exists" : " does not exist") + ); + if ((await IOUtils.exists(path)) != shouldExist) { + throw new Error( + "File " + path + " should " + (shouldExist ? "exist" : "not exist") + ); + } + })(); +} + +function promise_check_contents(path, expect) { + return (async function() { + info("Checking whether " + path + " has the right contents"); + let actual = await IOUtils.readJSON(path, { + decompress: true, + }); + Assert.deepEqual( + actual, + expect, + `File ${path} contains the expected data.` + ); + })(); +} + +// Check whether the migration from .js to .jslz4 is correct. +add_task(async function test_migration() { + let source = do_get_file("data/sessionstore_valid.js"); + source.copyTo(profd, "sessionstore.js"); + + // Read the content of the session store file. + let parsed = await IOUtils.readJSON(Paths.clean.replace("jsonlz4", "js")); + + // Read the session file with .js extension. + let result = await SessionFile.read(); + + // Check whether the result is what we wanted. + equal(result.origin, "clean"); + equal(result.useOldExtension, true); + Assert.deepEqual( + result.parsed, + parsed, + "result.parsed contains expected data" + ); + + // Initiate a write to ensure we write the compressed version. + await SessionFile.write(parsed); + await promise_check_exist(Paths.backups, true); + await promise_check_exist(Paths.clean, false); + await promise_check_exist(Paths.cleanBackup, true); + await promise_check_exist(Paths.recovery, true); + await promise_check_exist(Paths.recoveryBackup, false); + await promise_check_exist(Paths.nextUpgradeBackup, true); + // The deprecated $Path.clean should exist. + await promise_check_exist(Paths.clean.replace("jsonlz4", "js"), true); + + await promise_check_contents(Paths.recovery, parsed); +}); + +add_task(async function test_startup_with_compressed_clean() { + let state = { windows: [] }; + + // Mare sure we have an empty profile dir. + await SessionFile.wipe(); + + // Populate session files to profile dir. + await IOUtils.writeJSON(Paths.clean, state, { + compress: true, + }); + await IOUtils.makeDirectory(Paths.backups); + await IOUtils.writeJSON(Paths.cleanBackup, state, { + compress: true, + }); + + // Initiate a read. + let result = await SessionFile.read(); + + // Make sure we read correct session file and its content. + equal(result.origin, "clean"); + equal(result.useOldExtension, false); + Assert.deepEqual( + state, + result.parsed, + "result.parsed contains expected data" + ); +}); + +add_task(async function test_empty_profile_dir() { + // Make sure that we have empty profile dir. + await SessionFile.wipe(); + await promise_check_exist(Paths.backups, false); + await promise_check_exist(Paths.clean, false); + await promise_check_exist(Paths.cleanBackup, false); + await promise_check_exist(Paths.recovery, false); + await promise_check_exist(Paths.recoveryBackup, false); + await promise_check_exist(Paths.nextUpgradeBackup, false); + await promise_check_exist(Paths.backups.replace("jsonlz4", "js"), false); + await promise_check_exist(Paths.clean.replace("jsonlz4", "js"), false); + await promise_check_exist(Paths.cleanBackup.replace("lz4", ""), false); + await promise_check_exist(Paths.recovery.replace("jsonlz4", "js"), false); + await promise_check_exist( + Paths.recoveryBackup.replace("jsonlz4", "js"), + false + ); + await promise_check_exist( + Paths.nextUpgradeBackup.replace("jsonlz4", "js"), + false + ); + + // Initiate a read and make sure that we are in empty state. + let result = await SessionFile.read(); + equal(result.origin, "empty"); + equal(result.noFilesFound, true); + + // Create a state to store. + let state = { windows: [] }; + await SessionWriter.write(state, { isFinalWrite: true }); + + // Check session files are created, but not deprecated ones. + await promise_check_exist(Paths.clean, true); + await promise_check_exist(Paths.clean.replace("jsonlz4", "js"), false); + + // Check session file' content is correct. + await promise_check_contents(Paths.clean, state); +}); diff --git a/browser/components/sessionstore/test/unit/test_startup_invalid_session.js b/browser/components/sessionstore/test/unit/test_startup_invalid_session.js new file mode 100644 index 0000000000..50960b1d43 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_startup_invalid_session.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +function run_test() { + let profd = do_get_profile(); + var SessionFile = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" + ).SessionFile; + + let sourceSession = do_get_file("data/sessionstore_invalid.js"); + sourceSession.copyTo(profd, "sessionstore.js"); + + let sourceCheckpoints = do_get_file("data/sessionCheckpoints_all.json"); + sourceCheckpoints.copyTo(profd, "sessionCheckpoints.json"); + + // Compress sessionstore.js to sessionstore.jsonlz4 + // and remove sessionstore.js + let oldExtSessionFile = SessionFile.Paths.clean.replace("jsonlz4", "js"); + writeCompressedFile(oldExtSessionFile, SessionFile.Paths.clean).then(() => { + afterSessionStartupInitialization(function cb() { + Assert.equal(SessionStartup.sessionType, SessionStartup.NO_SESSION); + do_test_finished(); + }); + }); + + do_test_pending(); +} diff --git a/browser/components/sessionstore/test/unit/test_startup_nosession_async.js b/browser/components/sessionstore/test/unit/test_startup_nosession_async.js new file mode 100644 index 0000000000..259c393e63 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_startup_nosession_async.js @@ -0,0 +1,19 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test SessionStartup.sessionType in the following scenario: +// - no sessionstore.js; +// - the session store has been loaded, so no need to go +// through the synchronous fallback + +function run_test() { + // Initialize the profile (the session startup uses it) + do_get_profile(); + + do_test_pending(); + + afterSessionStartupInitialization(function cb() { + Assert.equal(SessionStartup.sessionType, SessionStartup.NO_SESSION); + do_test_finished(); + }); +} diff --git a/browser/components/sessionstore/test/unit/test_startup_session_async.js b/browser/components/sessionstore/test/unit/test_startup_session_async.js new file mode 100644 index 0000000000..a61c9fe422 --- /dev/null +++ b/browser/components/sessionstore/test/unit/test_startup_session_async.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test SessionStartup.sessionType in the following scenario: +// - valid sessionstore.js; +// - valid sessionCheckpoints.json with all checkpoints; +// - the session store has been loaded + +function run_test() { + let profd = do_get_profile(); + var SessionFile = ChromeUtils.importESModule( + "resource:///modules/sessionstore/SessionFile.sys.mjs" + ).SessionFile; + + let sourceSession = do_get_file("data/sessionstore_valid.js"); + sourceSession.copyTo(profd, "sessionstore.js"); + + let sourceCheckpoints = do_get_file("data/sessionCheckpoints_all.json"); + sourceCheckpoints.copyTo(profd, "sessionCheckpoints.json"); + + // Compress sessionstore.js to sessionstore.jsonlz4 + // and remove sessionstore.js + let oldExtSessionFile = SessionFile.Paths.clean.replace("jsonlz4", "js"); + writeCompressedFile(oldExtSessionFile, SessionFile.Paths.clean).then(() => { + afterSessionStartupInitialization(function cb() { + Assert.equal(SessionStartup.sessionType, SessionStartup.DEFER_SESSION); + do_test_finished(); + }); + }); + + do_test_pending(); +} diff --git a/browser/components/sessionstore/test/unit/xpcshell.ini b/browser/components/sessionstore/test/unit/xpcshell.ini new file mode 100644 index 0000000000..b5fadb609d --- /dev/null +++ b/browser/components/sessionstore/test/unit/xpcshell.ini @@ -0,0 +1,21 @@ +[DEFAULT] +head = head.js +tags = condprof +firefox-appdir = browser +skip-if = toolkit == 'android' # bug 1730213 +support-files = + data/sessionCheckpoints_all.json + data/sessionstore_invalid.js + data/sessionstore_valid.js + +[test_backup_once.js] +skip-if = condprof # 1769154 +[test_final_write_cleanup.js] +[test_histogram_corrupt_files.js] +[test_migration_lz4compression.js] +skip-if = condprof # 1769154 +[test_startup_nosession_async.js] +skip-if = condprof # 1769154 +[test_startup_session_async.js] +[test_startup_invalid_session.js] +skip-if = condprof # 1769154 |