diff options
Diffstat (limited to 'docshell/test/chrome/docshell_helpers.js')
-rw-r--r-- | docshell/test/chrome/docshell_helpers.js | 758 |
1 files changed, 758 insertions, 0 deletions
diff --git a/docshell/test/chrome/docshell_helpers.js b/docshell/test/chrome/docshell_helpers.js new file mode 100644 index 0000000000..facd627b45 --- /dev/null +++ b/docshell/test/chrome/docshell_helpers.js @@ -0,0 +1,758 @@ +if (!window.opener && window.arguments) { + window.opener = window.arguments[0]; +} +/** + * Import common SimpleTest methods so that they're usable in this window. + */ +/* globals SimpleTest, is, isnot, ok, onerror, todo, todo_is, todo_isnot */ +var imports = [ + "SimpleTest", + "is", + "isnot", + "ok", + "onerror", + "todo", + "todo_is", + "todo_isnot", +]; +for (var name of imports) { + window[name] = window.opener.wrappedJSObject[name]; +} +const { BrowserTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" +); + +const ACTOR_MODULE_URI = + "chrome://mochitests/content/chrome/docshell/test/chrome/DocShellHelpers.sys.mjs"; +const { DocShellHelpersParent } = ChromeUtils.importESModule(ACTOR_MODULE_URI); +// Some functions assume chrome-harness.js has been loaded. +/* import-globals-from ../../../testing/mochitest/chrome-harness.js */ + +/** + * Define global constants and variables. + */ +const NAV_NONE = 0; +const NAV_BACK = 1; +const NAV_FORWARD = 2; +const NAV_GOTOINDEX = 3; +const NAV_URI = 4; +const NAV_RELOAD = 5; + +var gExpectedEvents; // an array of events which are expected to +// be triggered by this navigation +var gUnexpectedEvents; // an array of event names which are NOT expected +// to be triggered by this navigation +var gFinalEvent; // true if the last expected event has fired +var gUrisNotInBFCache = []; // an array of uri's which shouldn't be stored +// in the bfcache +var gNavType = NAV_NONE; // defines the most recent navigation type +// executed by doPageNavigation +var gOrigMaxTotalViewers = undefined; // original value of max_total_viewers, // to be restored at end of test + +var gExtractedPath = null; // used to cache file path for extracting files from a .jar file + +/** + * The doPageNavigation() function performs page navigations asynchronously, + * listens for specified events, and compares actual events with a list of + * expected events. When all expected events have occurred, an optional + * callback can be notified. The parameter passed to this function is an + * object with the following properties: + * + * uri: if !undefined, the browser will navigate to this uri + * + * back: if true, the browser will execute goBack() + * + * forward: if true, the browser will execute goForward() + * + * gotoIndex: if a number, the browser will execute gotoIndex() with + * the number as index + * + * reload: if true, the browser will execute reload() + * + * eventsToListenFor: an array containing one or more of the following event + * types to listen for: "pageshow", "pagehide", "onload", + * "onunload". If this property is undefined, only a + * single "pageshow" events will be listened for. If this + * property is explicitly empty, [], then no events will + * be listened for. + * + * expectedEvents: an array of one or more expectedEvent objects, + * corresponding to the events which are expected to be + * fired for this navigation. Each object has the + * following properties: + * + * type: one of the event type strings + * title (optional): the title of the window the + * event belongs to + * persisted (optional): the event's expected + * .persisted attribute + * + * This function will verify that events with the + * specified properties are fired in the same order as + * specified in the array. If .title or .persisted + * properties for an expectedEvent are undefined, those + * properties will not be verified for that particular + * event. + * + * This property is ignored if eventsToListenFor is + * undefined or []. + * + * preventBFCache: if true, an RTCPeerConnection will be added to the loaded + * page to prevent it from being bfcached. This property + * has no effect when eventsToListenFor is []. + * + * onNavComplete: a callback which is notified after all expected events + * have occurred, or after a timeout has elapsed. This + * callback is not notified if eventsToListenFor is []. + * onGlobalCreation: a callback which is notified when a DOMWindow is created + * (implemented by observing + * "content-document-global-created") + * + * There must be an expectedEvent object for each event of the types in + * eventsToListenFor which is triggered by this navigation. For example, if + * eventsToListenFor = [ "pagehide", "pageshow" ], then expectedEvents + * must contain an object for each pagehide and pageshow event which occurs as + * a result of this navigation. + */ +// eslint-disable-next-line complexity +function doPageNavigation(params) { + // Parse the parameters. + let back = params.back ? params.back : false; + let forward = params.forward ? params.forward : false; + let gotoIndex = params.gotoIndex ? params.gotoIndex : false; + let reload = params.reload ? params.reload : false; + let uri = params.uri ? params.uri : false; + let eventsToListenFor = + typeof params.eventsToListenFor != "undefined" + ? params.eventsToListenFor + : ["pageshow"]; + gExpectedEvents = + typeof params.eventsToListenFor == "undefined" || !eventsToListenFor.length + ? undefined + : params.expectedEvents; + gUnexpectedEvents = + typeof params.eventsToListenFor == "undefined" || !eventsToListenFor.length + ? undefined + : params.unexpectedEvents; + let preventBFCache = + typeof [params.preventBFCache] == "undefined" + ? false + : params.preventBFCache; + let waitOnly = + typeof params.waitForEventsOnly == "boolean" && params.waitForEventsOnly; + + // Do some sanity checking on arguments. + let navigation = ["back", "forward", "gotoIndex", "reload", "uri"].filter(k => + params.hasOwnProperty(k) + ); + if (navigation.length > 1) { + throw new Error(`Can't specify both ${navigation[0]} and ${navigation[1]}`); + } else if (!navigation.length && !waitOnly) { + throw new Error( + "Must specify back or forward or gotoIndex or reload or uri" + ); + } + if (params.onNavComplete && !eventsToListenFor.length) { + throw new Error("Can't use onNavComplete when eventsToListenFor == []"); + } + if (params.preventBFCache && !eventsToListenFor.length) { + throw new Error("Can't use preventBFCache when eventsToListenFor == []"); + } + if (params.preventBFCache && waitOnly) { + throw new Error("Can't prevent bfcaching when only waiting for events"); + } + if (waitOnly && typeof params.onNavComplete == "undefined") { + throw new Error( + "Must specify onNavComplete when specifying waitForEventsOnly" + ); + } + if (waitOnly && navigation.length) { + throw new Error( + "Can't specify a navigation type when using waitForEventsOnly" + ); + } + for (let anEventType of eventsToListenFor) { + let eventFound = false; + if (anEventType == "pageshow" && !gExpectedEvents) { + eventFound = true; + } + if (gExpectedEvents) { + for (let anExpectedEvent of gExpectedEvents) { + if (anExpectedEvent.type == anEventType) { + eventFound = true; + } + } + } + if (gUnexpectedEvents) { + for (let anExpectedEventType of gUnexpectedEvents) { + if (anExpectedEventType == anEventType) { + eventFound = true; + } + } + } + if (!eventFound) { + throw new Error( + `Event type ${anEventType} is specified in ` + + "eventsToListenFor, but not in expectedEvents" + ); + } + } + + // If the test explicitly sets .eventsToListenFor to [], don't wait for any + // events. + gFinalEvent = !eventsToListenFor.length; + + // Add observers as needed. + let observers = new Map(); + if (params.hasOwnProperty("onGlobalCreation")) { + observers.set("content-document-global-created", params.onGlobalCreation); + } + + // Add an event listener for each type of event in the .eventsToListenFor + // property of the input parameters, and add an observer for all the topics + // in the observers map. + let cleanup; + let useActor = TestWindow.getBrowser().isRemoteBrowser; + if (useActor) { + ChromeUtils.registerWindowActor("DocShellHelpers", { + parent: { + esModuleURI: ACTOR_MODULE_URI, + }, + child: { + esModuleURI: ACTOR_MODULE_URI, + events: { + pageshow: { createActor: true, capture: true }, + pagehide: { createActor: true, capture: true }, + load: { createActor: true, capture: true }, + unload: { createActor: true, capture: true }, + visibilitychange: { createActor: true, capture: true }, + }, + observers: observers.keys(), + }, + allFrames: true, + }); + DocShellHelpersParent.eventsToListenFor = eventsToListenFor; + DocShellHelpersParent.observers = observers; + + cleanup = () => { + DocShellHelpersParent.eventsToListenFor = null; + DocShellHelpersParent.observers = null; + ChromeUtils.unregisterWindowActor("DocShellHelpers"); + }; + } else { + for (let eventType of eventsToListenFor) { + dump("TEST: registering a listener for " + eventType + " events\n"); + TestWindow.getBrowser().addEventListener( + eventType, + pageEventListener, + true + ); + } + if (observers.size > 0) { + let observer = (_, topic) => { + observers.get(topic).call(); + }; + for (let topic of observers.keys()) { + Services.obs.addObserver(observer, topic); + } + + // We only need to do cleanup for the observer, the event listeners will + // go away with the window. + cleanup = () => { + for (let topic of observers.keys()) { + Services.obs.removeObserver(observer, topic); + } + }; + } + } + + if (cleanup) { + // Register a cleanup function on domwindowclosed, to avoid contaminating + // other tests if we bail out early because of an error. + Services.ww.registerNotification(function windowClosed( + subject, + topic, + data + ) { + if (topic == "domwindowclosed" && subject == window) { + Services.ww.unregisterNotification(windowClosed); + cleanup(); + } + }); + } + + // Perform the specified navigation. + if (back) { + gNavType = NAV_BACK; + TestWindow.getBrowser().goBack(); + } else if (forward) { + gNavType = NAV_FORWARD; + TestWindow.getBrowser().goForward(); + } else if (typeof gotoIndex == "number") { + gNavType = NAV_GOTOINDEX; + TestWindow.getBrowser().gotoIndex(gotoIndex); + } else if (uri) { + gNavType = NAV_URI; + BrowserTestUtils.loadURI(TestWindow.getBrowser(), uri); + } else if (reload) { + gNavType = NAV_RELOAD; + TestWindow.getBrowser().reload(); + } else if (waitOnly) { + gNavType = NAV_NONE; + } else { + throw new Error("No valid navigation type passed to doPageNavigation!"); + } + + // If we're listening for events and there is an .onNavComplete callback, + // wait for all events to occur, and then call doPageNavigation_complete(). + if (eventsToListenFor.length && params.onNavComplete) { + waitForTrue( + function() { + return gFinalEvent; + }, + function() { + doPageNavigation_complete( + eventsToListenFor, + params.onNavComplete, + preventBFCache, + useActor, + cleanup + ); + } + ); + } else if (cleanup) { + cleanup(); + } +} + +/** + * Finish doPageNavigation(), by removing event listeners, adding an unload + * handler if appropriate, and calling the onNavComplete callback. This + * function is called after all the expected events for this navigation have + * occurred. + */ +function doPageNavigation_complete( + eventsToListenFor, + onNavComplete, + preventBFCache, + useActor, + cleanup +) { + if (useActor) { + if (preventBFCache) { + let actor = TestWindow.getBrowser().browsingContext.currentWindowGlobal.getActor( + "DocShellHelpers" + ); + actor.sendAsyncMessage("docshell_helpers:preventBFCache"); + } + } else { + // Unregister our event listeners. + dump("TEST: removing event listeners\n"); + for (let eventType of eventsToListenFor) { + TestWindow.getBrowser().removeEventListener( + eventType, + pageEventListener, + true + ); + } + + // If the .preventBFCache property was set, add an RTCPeerConnection to + // prevent the page from being bfcached. + if (preventBFCache) { + let win = TestWindow.getWindow(); + win.blockBFCache = new win.RTCPeerConnection(); + } + } + + if (cleanup) { + cleanup(); + } + + let uri = TestWindow.getBrowser().currentURI.spec; + if (preventBFCache) { + // Save the current uri in an array of uri's which shouldn't be + // stored in the bfcache, for later verification. + if (!(uri in gUrisNotInBFCache)) { + gUrisNotInBFCache.push(uri); + } + } else if (gNavType == NAV_URI) { + // If we're navigating to a uri and .preventBFCache was not + // specified, splice it out of gUrisNotInBFCache if it's there. + gUrisNotInBFCache.forEach(function(element, index, array) { + if (element == uri) { + array.splice(index, 1); + } + }, this); + } + + // Notify the callback now that we're done. + onNavComplete.call(); +} + +function promisePageNavigation(params) { + if (params.hasOwnProperty("onNavComplete")) { + throw new Error( + "Can't use a onNavComplete completion callback with promisePageNavigation." + ); + } + return new Promise(resolve => { + params.onNavComplete = resolve; + doPageNavigation(params); + }); +} + +/** + * Allows a test to wait for page navigation events, and notify a + * callback when they've all been received. This works exactly the + * same as doPageNavigation(), except that no navigation is initiated. + */ +function waitForPageEvents(params) { + params.waitForEventsOnly = true; + doPageNavigation(params); +} + +function promisePageEvents(params) { + if (params.hasOwnProperty("onNavComplete")) { + throw new Error( + "Can't use a onNavComplete completion callback with promisePageEvents." + ); + } + return new Promise(resolve => { + params.waitForEventsOnly = true; + params.onNavComplete = resolve; + doPageNavigation(params); + }); +} + +/** + * The event listener which listens for expectedEvents. + */ +function pageEventListener( + event, + originalTargetIsHTMLDocument = HTMLDocument.isInstance(event.originalTarget) +) { + try { + dump( + "TEST: eventListener received a " + + event.type + + " event for page " + + event.originalTarget.title + + ", persisted=" + + event.persisted + + "\n" + ); + } catch (e) { + // Ignore any exception. + } + + // If this page shouldn't be in the bfcache because it was previously + // loaded with .preventBFCache, make sure that its pageshow event + // has .persisted = false, even if the test doesn't explicitly test + // for .persisted. + if ( + event.type == "pageshow" && + (gNavType == NAV_BACK || + gNavType == NAV_FORWARD || + gNavType == NAV_GOTOINDEX) + ) { + let uri = TestWindow.getBrowser().currentURI.spec; + if (uri in gUrisNotInBFCache) { + ok( + !event.persisted, + "pageshow event has .persisted = false, even " + + "though it was loaded with .preventBFCache previously\n" + ); + } + } + + if (typeof gUnexpectedEvents != "undefined") { + is( + gUnexpectedEvents.indexOf(event.type), + -1, + "Should not get unexpected event " + event.type + ); + } + + // If no expected events were specified, mark the final event as having been + // triggered when a pageshow event is fired; this will allow + // doPageNavigation() to return. + if (typeof gExpectedEvents == "undefined" && event.type == "pageshow") { + waitForNextPaint(function() { + gFinalEvent = true; + }); + return; + } + + // If there are explicitly no expected events, but we receive one, it's an + // error. + if (!gExpectedEvents.length) { + ok(false, "Unexpected event (" + event.type + ") occurred"); + return; + } + + // Grab the next expected event, and compare its attributes against the + // actual event. + let expected = gExpectedEvents.shift(); + + is( + event.type, + expected.type, + "A " + + expected.type + + " event was expected, but a " + + event.type + + " event occurred" + ); + + if (typeof expected.title != "undefined") { + ok( + originalTargetIsHTMLDocument, + "originalTarget for last " + event.type + " event not an HTMLDocument" + ); + is( + event.originalTarget.title, + expected.title, + "A " + + event.type + + " event was expected for page " + + expected.title + + ", but was fired for page " + + event.originalTarget.title + ); + } + + if (typeof expected.persisted != "undefined") { + is( + event.persisted, + expected.persisted, + "The persisted property of the " + + event.type + + " event on page " + + event.originalTarget.location + + " had an unexpected value" + ); + } + + if ("visibilityState" in expected) { + is( + event.originalTarget.visibilityState, + expected.visibilityState, + "The visibilityState property of the document on page " + + event.originalTarget.location + + " had an unexpected value" + ); + } + + if ("hidden" in expected) { + is( + event.originalTarget.hidden, + expected.hidden, + "The hidden property of the document on page " + + event.originalTarget.location + + " had an unexpected value" + ); + } + + // If we're out of expected events, let doPageNavigation() return. + if (!gExpectedEvents.length) { + waitForNextPaint(function() { + gFinalEvent = true; + }); + } +} + +DocShellHelpersParent.eventListener = pageEventListener; + +/** + * End a test. + */ +function finish() { + // Work around bug 467960. + let historyPurged; + if (SpecialPowers.Services.appinfo.sessionHistoryInParent) { + let history = TestWindow.getBrowser().browsingContext?.sessionHistory; + history.purgeHistory(history.count); + historyPurged = Promise.resolve(); + } else { + historyPurged = SpecialPowers.spawn(TestWindow.getBrowser(), [], () => { + let history = docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory + .legacySHistory; + history.purgeHistory(history.count); + }); + } + + // If the test changed the value of max_total_viewers via a call to + // enableBFCache(), then restore it now. + if (typeof gOrigMaxTotalViewers != "undefined") { + Services.prefs.setIntPref( + "browser.sessionhistory.max_total_viewers", + gOrigMaxTotalViewers + ); + } + + // Close the test window and signal the framework that the test is done. + let opener = window.opener; + let SimpleTest = opener.wrappedJSObject.SimpleTest; + + // Wait for the window to be closed before finishing the test + Services.ww.registerNotification(function observer(subject, topic, data) { + if (topic == "domwindowclosed") { + Services.ww.unregisterNotification(observer); + SimpleTest.waitForFocus(SimpleTest.finish, opener); + } + }); + + historyPurged.then(_ => { + window.close(); + }); +} + +/** + * Helper function which waits until another function returns true, or until a + * timeout occurs, and then notifies a callback. + * + * Parameters: + * + * fn: a function which is evaluated repeatedly, and when it turns true, + * the onWaitComplete callback is notified. + * + * onWaitComplete: a callback which will be notified when fn() returns + * true, or when a timeout occurs. + * + * timeout: a timeout, in seconds or ms, after which waitForTrue() will + * fail an assertion and then return, even if the fn function never + * returns true. If timeout is undefined, waitForTrue() will never + * time out. + */ +function waitForTrue(fn, onWaitComplete, timeout) { + promiseTrue(fn, timeout).then(() => { + onWaitComplete.call(); + }); +} + +function promiseTrue(fn, timeout) { + if (typeof timeout != "undefined") { + // If timeoutWait is less than 500, assume it represents seconds, and + // convert to ms. + if (timeout < 500) { + timeout *= 1000; + } + } + + // Loop until the test function returns true, or until a timeout occurs, + // if a timeout is defined. + let intervalid, timeoutid; + let condition = new Promise(resolve => { + intervalid = setInterval(async () => { + if (await fn.call()) { + resolve(); + } + }, 20); + }); + if (typeof timeout != "undefined") { + condition = Promise.race([ + condition, + new Promise((_, reject) => { + timeoutid = setTimeout(() => { + reject(); + }, timeout); + }), + ]); + } + return condition + .finally(() => { + clearInterval(intervalid); + }) + .then(() => { + clearTimeout(timeoutid); + }); +} + +function waitForNextPaint(cb) { + requestAnimationFrame(_ => requestAnimationFrame(cb)); +} + +function promiseNextPaint() { + return new Promise(resolve => { + waitForNextPaint(resolve); + }); +} + +/** + * Enable or disable the bfcache. + * + * Parameters: + * + * enable: if true, set max_total_viewers to -1 (the default); if false, set + * to 0 (disabled), if a number, set it to that specific number + */ +function enableBFCache(enable) { + // If this is the first time the test called enableBFCache(), + // store the original value of max_total_viewers, so it can + // be restored at the end of the test. + if (typeof gOrigMaxTotalViewers == "undefined") { + gOrigMaxTotalViewers = Services.prefs.getIntPref( + "browser.sessionhistory.max_total_viewers" + ); + } + + if (typeof enable == "boolean") { + if (enable) { + Services.prefs.setIntPref("browser.sessionhistory.max_total_viewers", -1); + } else { + Services.prefs.setIntPref("browser.sessionhistory.max_total_viewers", 0); + } + } else if (typeof enable == "number") { + Services.prefs.setIntPref( + "browser.sessionhistory.max_total_viewers", + enable + ); + } +} + +/* + * get http root for local tests. Use a single extractJarToTmp instead of + * extracting for each test. + * Returns a file://path if we have a .jar file + */ +function getHttpRoot() { + var location = window.location.href; + location = getRootDirectory(location); + var jar = getJar(location); + if (jar != null) { + if (gExtractedPath == null) { + var resolved = extractJarToTmp(jar); + gExtractedPath = resolved.path; + } + } else { + return null; + } + return "file://" + gExtractedPath + "/"; +} + +/** + * Returns the full HTTP url for a file in the mochitest docshell test + * directory. + */ +function getHttpUrl(filename) { + var root = getHttpRoot(); + if (root == null) { + root = "http://mochi.test:8888/chrome/docshell/test/chrome/"; + } + return root + filename; +} + +/** + * A convenience object with methods that return the current test window, + * browser, and document. + */ +var TestWindow = {}; +TestWindow.getWindow = function() { + return document.getElementById("content").contentWindow; +}; +TestWindow.getBrowser = function() { + return document.getElementById("content"); +}; +TestWindow.getDocument = function() { + return document.getElementById("content").contentDocument; +}; |