/* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; // Test the ResourceCommand API around DOCUMENT_EVENT add_task(async function () { await testDocumentEventResources(); await testDocumentEventResourcesWithIgnoreExistingResources(); await testDomCompleteWithOverloadedConsole(); await testIframeNavigation(); await testBfCacheNavigation(); await testDomCompleteWithWindowStop(); await testCrossOriginNavigation(); await testDomCompleteWithOfflineDocument(); }); async function testDocumentEventResources() { info("Test ResourceCommand for DOCUMENT_EVENT"); // Open a test tab const title = "DocumentEventsTitle"; const url = `data:text/html,${title}Document Events`; const tab = await addTab(url); const listener = new ResourceListener(); const { commands } = await initResourceCommand(tab); info( "Check whether the document events are fired correctly even when the document was already loaded" ); const onLoadingAtInit = listener.once("dom-loading"); const onInteractiveAtInit = listener.once("dom-interactive"); const onCompleteAtInit = listener.once("dom-complete"); await commands.resourceCommand.watchResources( [commands.resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: parameters => listener.dispatch(parameters), } ); await assertPromises( commands, // targetBeforeNavigation is only used when there is a will-navigate and a navigate, but there is none here null, // As we started watching on an already loaded document, and no navigation happened since we called watchResources, // we don't have any will-navigate event null, onLoadingAtInit, onInteractiveAtInit, onCompleteAtInit ); ok( true, "Document events are fired even when the document was already loaded" ); let domLoadingResource = await onLoadingAtInit; is( domLoadingResource.url, url, `resource ${domLoadingResource.name} has expected url` ); is( domLoadingResource.title, undefined, `resource ${domLoadingResource.name} does not have a title property` ); let domInteractiveResource = await onInteractiveAtInit; is( domInteractiveResource.url, url, `resource ${domInteractiveResource.name} has expected url` ); is( domInteractiveResource.title, title, `resource ${domInteractiveResource.name} has expected title` ); let domCompleteResource = await onCompleteAtInit; is( domCompleteResource.url, undefined, `resource ${domCompleteResource.name} does not have a url property` ); is( domCompleteResource.title, undefined, `resource ${domCompleteResource.name} does not have a title property` ); info("Check whether the document events are fired correctly when reloading"); const onWillNavigate = listener.once("will-navigate"); const onLoadingAtReloaded = listener.once("dom-loading"); const onInteractiveAtReloaded = listener.once("dom-interactive"); const onCompleteAtReloaded = listener.once("dom-complete"); const targetBeforeNavigation = commands.targetCommand.targetFront; gBrowser.reloadTab(tab); await assertPromises( commands, targetBeforeNavigation, onWillNavigate, onLoadingAtReloaded, onInteractiveAtReloaded, onCompleteAtReloaded ); ok(true, "Document events are fired after reloading"); domLoadingResource = await onLoadingAtReloaded; is( domLoadingResource.url, url, `resource ${domLoadingResource.name} has expected url after reloading` ); is( domLoadingResource.title, undefined, `resource ${domLoadingResource.name} does not have a title property after reloading` ); domInteractiveResource = await onInteractiveAtInit; is( domInteractiveResource.url, url, `resource ${domInteractiveResource.name} has url property after reloading` ); is( domInteractiveResource.title, title, `resource ${domInteractiveResource.name} has expected title after reloading` ); domCompleteResource = await onCompleteAtInit; is( domCompleteResource.url, undefined, `resource ${domCompleteResource.name} does not have a url property after reloading` ); is( domCompleteResource.title, undefined, `resource ${domCompleteResource.name} does not have a title property after reloading` ); await commands.destroy(); } async function testDocumentEventResourcesWithIgnoreExistingResources() { info("Test ignoreExistingResources option for DOCUMENT_EVENT"); const tab = await addTab("data:text/html,Document Events"); const { commands } = await initResourceCommand(tab); info("Check whether the existing document events will not be fired"); const documentEvents = []; await commands.resourceCommand.watchResources( [commands.resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: resources => documentEvents.push(...resources), ignoreExistingResources: true, } ); is(documentEvents.length, 0, "Existing document events are not fired"); info("Check whether the future document events are fired"); const targetBeforeNavigation = commands.targetCommand.targetFront; gBrowser.reloadTab(tab); info( "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" ); await waitFor(() => documentEvents.length === 4); assertEvents({ commands, targetBeforeNavigation, documentEvents }); await commands.destroy(); } async function testIframeNavigation() { info("Test iframe navigations for DOCUMENT_EVENT"); const tab = await addTab( 'https://example.com/document-builder.sjs?html=' ); const secondPageUrl = "https://example.org/document-builder.sjs?html=org"; const { commands } = await initResourceCommand(tab); let documentEvents = []; await commands.resourceCommand.watchResources( [commands.resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: resources => documentEvents.push(...resources), } ); is( documentEvents.length, 6, "We get two targets and two sets of events: dom-loading, dom-interactive, dom-complete" ); const [, iframeTarget] = await commands.targetCommand.getAllTargets([ commands.targetCommand.TYPES.FRAME, ]); // Filter out each target events as their order to be random between the two targets const topTargetEvents = documentEvents.filter( r => r.targetFront == commands.targetCommand.targetFront ); const iframeTargetEvents = documentEvents.filter( r => r.targetFront != commands.targetCommand.targetFront ); assertEvents({ commands, documentEvents: [null /* no will-navigate */, ...topTargetEvents], }); assertEvents({ commands, documentEvents: [null /* no will-navigate */, ...iframeTargetEvents], expectedTargetFront: iframeTarget, }); info("Navigate the iframe to another process (if fission is enabled)"); documentEvents = []; await SpecialPowers.spawn( gBrowser.selectedBrowser, [secondPageUrl], function (url) { const iframe = content.document.querySelector("iframe"); iframe.src = url; } ); await waitFor(() => documentEvents.length >= 3); is( documentEvents.length, 3, "We switch to a new target and get: dom-loading, dom-interactive, dom-complete (but no will-navigate as that's only for the top BrowsingContext)" ); const [, newIframeTarget] = await commands.targetCommand.getAllTargets([ commands.targetCommand.TYPES.FRAME, ]); assertEvents({ commands, targetBeforeNavigation: iframeTarget, documentEvents: [null /* no will-navigate */, ...documentEvents], expectedTargetFront: newIframeTarget, expectedNewURI: secondPageUrl, }); await commands.destroy(); } function isBfCacheInParentEnabled() { return Services.prefs.getBoolPref("fission.bfcacheInParent", false); } async function testBfCacheNavigation() { info("Test bfcache navigations for DOCUMENT_EVENT"); info("Open a first document and navigate to a second one"); const firstLocation = "data:text/html,firstfirst page"; const secondLocation = "data:text/html,secondsecond page"; const tab = await addTab(firstLocation); const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); BrowserTestUtils.startLoadingURIString( gBrowser.selectedBrowser, secondLocation ); await onLoaded; const { commands } = await initResourceCommand(tab); const documentEvents = []; await commands.resourceCommand.watchResources( [commands.resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: resources => { documentEvents.push(...resources); }, ignoreExistingResources: true, } ); // Wait for some time for extra safety await wait(250); is(documentEvents.length, 0, "Existing document events are not fired"); info("Navigate back to the first page"); const onSwitched = commands.targetCommand.once("switched-target"); const targetBeforeNavigation = commands.targetCommand.targetFront; gBrowser.goBack(); if (isBfCacheInParentEnabled()) { await onSwitched; } info( "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" ); await waitFor(() => documentEvents.length >= 4); /* Ignore will-navigate timestamp as all other DOCUMENT_EVENTS will be set at the original load date, which is when we loaded from the network, and not when we loaded from bfcache */ assertEvents({ commands, targetBeforeNavigation, documentEvents, ignoreWillNavigateTimestamp: true, }); // Wait for some time in order to let a chance to have duplicated dom-loading events await wait(250); is( documentEvents.length, 4, "There is no duplicated event and only the 4 expected DOCUMENT_EVENT states" ); const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] = documentEvents; is( willNavigateEvent.name, "will-navigate", "The first DOCUMENT_EVENT is will-navigate" ); is( loadingEvent.name, "dom-loading", "The second DOCUMENT_EVENT is dom-loading" ); is( interactiveEvent.name, "dom-interactive", "The third DOCUMENT_EVENT is dom-interactive" ); is( completeEvent.name, "dom-complete", "The fourth DOCUMENT_EVENT is dom-complete" ); is( loadingEvent.url, firstLocation, `resource ${loadingEvent.name} has expected url after navigation back` ); is( loadingEvent.title, undefined, `resource ${loadingEvent.name} does not have a title property after navigating back` ); is( interactiveEvent.url, firstLocation, `resource ${interactiveEvent.name} has expected url property after navigating back` ); is( interactiveEvent.title, "first", `resource ${interactiveEvent.name} has expected title after navigating back` ); is( completeEvent.url, undefined, `resource ${completeEvent.name} does not have a url property after navigating back` ); is( completeEvent.title, undefined, `resource ${completeEvent.name} does not have a title property after navigating back` ); await commands.destroy(); } async function testCrossOriginNavigation() { info("Test cross origin navigations for DOCUMENT_EVENT"); const tab = await addTab("https://example.com/document-builder.sjs?html=com"); const { commands } = await initResourceCommand(tab); const documentEvents = []; await commands.resourceCommand.watchResources( [commands.resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: resources => documentEvents.push(...resources), ignoreExistingResources: true, } ); // Wait for some time for extra safety await wait(250); is(documentEvents.length, 0, "Existing document events are not fired"); info("Navigate to another process"); const onSwitched = commands.targetCommand.once("switched-target"); const netUrl = "https://example.net/document-builder.sjs?html=titleNetnet"; const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); const targetBeforeNavigation = commands.targetCommand.targetFront; BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, netUrl); await onLoaded; await onSwitched; info( "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" ); await waitFor(() => documentEvents.length >= 4); assertEvents({ commands, targetBeforeNavigation, documentEvents }); // Wait for some time in order to let a chance to have duplicated dom-loading events await wait(250); is( documentEvents.length, 4, "There is no duplicated event and only the 4 expected DOCUMENT_EVENT states" ); const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] = documentEvents; is( willNavigateEvent.name, "will-navigate", "The first DOCUMENT_EVENT is will-navigate" ); is( loadingEvent.name, "dom-loading", "The second DOCUMENT_EVENT is dom-loading" ); is( interactiveEvent.name, "dom-interactive", "The third DOCUMENT_EVENT is dom-interactive" ); is( completeEvent.name, "dom-complete", "The fourth DOCUMENT_EVENT is dom-complete" ); is( loadingEvent.url, encodeURI(netUrl), `resource ${loadingEvent.name} has expected url after reloading` ); is( loadingEvent.title, undefined, `resource ${loadingEvent.name} does not have a title property after reloading` ); is( interactiveEvent.url, encodeURI(netUrl), `resource ${interactiveEvent.name} has expected url property after reloading` ); is( interactiveEvent.title, "titleNet", `resource ${interactiveEvent.name} has expected title after reloading` ); is( completeEvent.url, undefined, `resource ${completeEvent.name} does not have a url property after reloading` ); is( completeEvent.title, undefined, `resource ${completeEvent.name} does not have a title property after reloading` ); await commands.destroy(); } async function testDomCompleteWithOverloadedConsole() { info("Test dom-complete with an overloaded console object"); const tab = await addTab( "data:text/html," ); const { client, resourceCommand, targetCommand } = await initResourceCommand(tab); info("Check that all DOCUMENT_EVENTS are fired for the already loaded page"); const documentEvents = []; await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: resources => documentEvents.push(...resources), }); is(documentEvents.length, 3, "Existing document events are fired"); const domComplete = documentEvents[2]; is(domComplete.name, "dom-complete", "the last resource is the dom-complete"); is( domComplete.hasNativeConsoleAPI, false, "the console object is reported to be overloaded" ); targetCommand.destroy(); await client.close(); } async function testDomCompleteWithWindowStop() { info("Test dom-complete with a page calling window.stop()"); const tab = await addTab("data:text/html,foo"); const { commands, client, resourceCommand, targetCommand } = await initResourceCommand(tab); info("Check that all DOCUMENT_EVENTS are fired for the already loaded page"); let documentEvents = []; await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: resources => documentEvents.push(...resources), }); is(documentEvents.length, 3, "Existing document events are fired"); documentEvents = []; const html = ` stopped page Page content that shouldn't be displayed `; const secondLocation = "data:text/html," + encodeURIComponent(html); const targetBeforeNavigation = commands.targetCommand.targetFront; BrowserTestUtils.startLoadingURIString( gBrowser.selectedBrowser, secondLocation ); info( "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" ); await waitFor(() => documentEvents.length === 4); assertEvents({ commands, targetBeforeNavigation, documentEvents }); targetCommand.destroy(); await client.close(); } async function testDomCompleteWithOfflineDocument() { info("Test dom-complete with an offline page"); const tab = await addTab(`${URL_ROOT_SSL}empty.html`); const { commands, client, resourceCommand, targetCommand } = await initResourceCommand(tab); info("Check that all DOCUMENT_EVENTS are fired for the already loaded page"); let documentEvents = []; await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { onAvailable: resources => documentEvents.push(...resources), }); is(documentEvents.length, 3, "Existing document events are fired"); documentEvents = []; const targetBeforeNavigation = commands.targetCommand.targetFront; tab.linkedBrowser.browsingContext.forceOffline = true; gBrowser.reloadTab(tab); // The offline mode may break Document Event Watcher and we would miss some of the expected events info( "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" ); await waitFor(() => documentEvents.length === 4); // Only will-navigate will have a valid timestamp, as the page is failing loading // and we get the offline notice page, the other events will be set to 0 assertEvents({ commands, targetBeforeNavigation, documentEvents, ignoreAllTimestamps: true, }); targetCommand.destroy(); await client.close(); } async function assertPromises( commands, targetBeforeNavigation, onWillNavigate, onLoading, onInteractive, onComplete ) { const willNavigateEvent = await onWillNavigate; const loadingEvent = await onLoading; const interactiveEvent = await onInteractive; const completeEvent = await onComplete; assertEvents({ commands, targetBeforeNavigation, documentEvents: [ willNavigateEvent, loadingEvent, interactiveEvent, completeEvent, ], }); } const isSlowPlatform = AppConstants.ASAN || AppConstants.DEBUG || AppConstants.TSAN; function assertEvents({ commands, targetBeforeNavigation, documentEvents, expectedTargetFront = commands.targetCommand.targetFront, expectedNewURI = gBrowser.selectedBrowser.currentURI.spec, // The will-navigate timestamp has a hardcoded offset of 20ms to be "earlier" // than dom-content-loaded, but it frequently fails on debug/asan/tsan. // The observed failures are within < 5ms. ignoreWillNavigateTimestamp = isSlowPlatform, ignoreAllTimestamps = false, }) { const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] = documentEvents; if (willNavigateEvent) { is(willNavigateEvent.name, "will-navigate", "Received the will-navigate"); is( willNavigateEvent.newURI, expectedNewURI, "will-navigate newURI is set to the current tab new location" ); } is( loadingEvent.name, "dom-loading", "loading received in the exepected order" ); is( interactiveEvent.name, "dom-interactive", "interactive received in the expected order" ); is(completeEvent.name, "dom-complete", "complete received last"); if (willNavigateEvent) { is( typeof willNavigateEvent.time, "number", `Type of time attribute for will-navigate event is correct (${willNavigateEvent.time})` ); } is( typeof loadingEvent.time, "number", `Type of time attribute for loading event is correct (${loadingEvent.time})` ); is( typeof interactiveEvent.time, "number", `Type of time attribute for interactive event is correct (${interactiveEvent.time})` ); is( typeof completeEvent.time, "number", `Type of time attribute for complete event is correct (${completeEvent.time})` ); // In case of errors the timestamps may be set to 0. if (!ignoreAllTimestamps) { if (willNavigateEvent && !ignoreWillNavigateTimestamp) { Assert.lessOrEqual( willNavigateEvent.time, loadingEvent.time, `Timestamp for dom-loading event is greater than will-navigate event (${willNavigateEvent.time} <= ${loadingEvent.time})` ); } Assert.lessOrEqual( loadingEvent.time, interactiveEvent.time, `Timestamp for interactive event is greater than loading event (${loadingEvent.time} <= ${interactiveEvent.time})` ); Assert.lessOrEqual( interactiveEvent.time, completeEvent.time, `Timestamp for complete event is greater than interactive event (${interactiveEvent.time} <= ${completeEvent.time}).` ); } if (willNavigateEvent) { // If we switched to a new target, this target will be different from currentTargetFront. // This only happen if we navigate to another process or if server target switching is enabled. is( willNavigateEvent.targetFront, targetBeforeNavigation, "will-navigate target was the one before the navigation" ); } is( loadingEvent.targetFront, expectedTargetFront, "loading target is the expected one" ); is( interactiveEvent.targetFront, expectedTargetFront, "interactive target is the expected one" ); is( completeEvent.targetFront, expectedTargetFront, "complete target is the expected one" ); is( completeEvent.hasNativeConsoleAPI, true, "None of the tests (except the dedicated one) overload the console object" ); } class ResourceListener { _listeners = new Map(); dispatch(resources) { for (const resource of resources) { const resolve = this._listeners.get(resource.name); if (resolve) { resolve(resource); this._listeners.delete(resource.name); } } } once(resourceName) { return new Promise(r => this._listeners.set(resourceName, r)); } }