diff options
Diffstat (limited to 'devtools/shared/resources/tests')
51 files changed, 6685 insertions, 0 deletions
diff --git a/devtools/shared/resources/tests/.eslintrc.js b/devtools/shared/resources/tests/.eslintrc.js new file mode 100644 index 0000000000..3d0bd99e1b --- /dev/null +++ b/devtools/shared/resources/tests/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + extends: "../../../.eslintrc.mochitests.js", +}; diff --git a/devtools/shared/resources/tests/browser.ini b/devtools/shared/resources/tests/browser.ini new file mode 100644 index 0000000000..afbc008130 --- /dev/null +++ b/devtools/shared/resources/tests/browser.ini @@ -0,0 +1,64 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + !/devtools/client/shared/test/shared-head.js + !/devtools/client/shared/test/telemetry-test-helpers.js + !/devtools/client/shared/test/test-actor.js + head.js + network_document.html + early_console_document.html + fission_document.html + fission_iframe.html + service-worker-sources.js + sources.html + sources.js + style_document.css + style_document.html + style_iframe.css + style_iframe.html + test_service_worker.js + test_sw_page.html + test_sw_page_worker.js + test_worker.js + websocket_backend_wsh.py + websocket_frontend.html + worker-sources.js + +[browser_browser_resources_console_messages.js] +[browser_resources_client_caching.js] +[browser_resources_console_messages.js] +[browser_resources_console_messages_workers.js] +[browser_resources_css_changes.js] +[browser_resources_css_messages.js] +[browser_resources_document_events.js] +[browser_resources_error_messages.js] +[browser_resources_getAllResources.js] +[browser_resources_network_event_stacktraces.js] +[browser_resources_network_events.js] +[browser_resources_platform_messages.js] +[browser_resources_root_node.js] +[browser_resources_several_resources.js] +[browser_resources_sources.js] +[browser_resources_stylesheets.js] +skip-if = fission # Disable frequent fission intermittents Bug 1675020 +[browser_resources_target_destroy.js] +[browser_resources_target_resources_race.js] +[browser_resources_target_switching.js] +[browser_resources_websocket.js] +[browser_target_list_browser_workers.js] +[browser_target_list_frames.js] +[browser_target_list_getAllTargets.js] +[browser_target_list_preffedoff.js] +[browser_target_list_processes.js] +[browser_target_list_service_workers.js] +[browser_target_list_service_workers_navigation.js] +skip-if = fission +# There are several issues to test TargetList navigation scenarios with fission. +# Without a toolbox linked to the target-list, the target list cannot switch +# targets. The legacy worker watchers are also not designed to support target +# switching, since they set this.target = targetList.targetFront just once in +# their constructor. +[browser_target_list_switchToTarget.js] +[browser_target_list_tab_workers.js] +[browser_target_list_watchTargets.js] diff --git a/devtools/shared/resources/tests/browser_browser_resources_console_messages.js b/devtools/shared/resources/tests/browser_browser_resources_console_messages.js new file mode 100644 index 0000000000..67bd5af635 --- /dev/null +++ b/devtools/shared/resources/tests/browser_browser_resources_console_messages.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around CONSOLE_MESSAGE for the whole browser + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const TEST_URL = URL_ROOT_SSL + "early_console_document.html"; + +add_task(async function() { + // Enable Multiprocess Browser Toolbox (it's still disabled for non-Nightly builds). + await pushPref("devtools.browsertoolbox.fission", true); + + const { + client, + resourceWatcher, + targetList, + } = await initMultiProcessResourceWatcher(); + + info( + "Log some messages *before* calling ResourceWatcher.watchResources in order to " + + "assert the behavior of already existing messages." + ); + console.log("foobar"); + + info("Wait for existing browser mochitest log"); + await waitForNextResource( + resourceWatcher, + ResourceWatcher.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: false, + predicate({ message }) { + return message.arguments[0] === "foobar"; + }, + } + ); + ok(true, "The existing log was retrieved"); + + // We can't use waitForNextResource here as we have to ensure + // waiting for watchResource resolution before doing the console log. + let resolveMochitestRuntimeLog; + const onMochitestRuntimeLog = new Promise(resolve => { + resolveMochitestRuntimeLog = resolve; + }); + const onAvailable = resources => { + if ( + resources.some(resource => resource.message.arguments[0] == "foobar2") + ) { + resourceWatcher.unwatchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + resolveMochitestRuntimeLog(); + } + }; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + ignoreExistingResources: true, + onAvailable, + } + ); + console.log("foobar2"); + + info("Wait for runtime browser mochitest log"); + await onMochitestRuntimeLog; + ok(true, "The runtime log was retrieved"); + + const onEarlyLog = waitForNextResource( + resourceWatcher, + ResourceWatcher.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: true, + predicate({ message }) { + return message.arguments[0] === "early-page-log"; + }, + } + ); + await addTab(TEST_URL); + info("Wait for early page log"); + await onEarlyLog; + ok(true, "The early page log was retrieved"); + + targetList.destroy(); + await client.close(); +}); diff --git a/devtools/shared/resources/tests/browser_resources_client_caching.js b/devtools/shared/resources/tests/browser_resources_client_caching.js new file mode 100644 index 0000000000..b758318a13 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_client_caching.js @@ -0,0 +1,362 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the cache mechanism of the ResourceWatcher. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const TEST_URI = "data:text/html;charset=utf-8,Cache Test"; + +add_task(async function() { + info("Test whether multiple listener can get same cached resources"); + + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Add messages as existing resources"); + const messages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, messages); + + info("Register first listener"); + const cachedResources1 = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources1.push(...resources), + } + ); + + info("Register second listener"); + const cachedResources2 = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources2.push(...resources), + } + ); + + assertContents(cachedResources1, messages); + assertResources(cachedResources2, cachedResources1); + + await targetList.destroy(); + await client.close(); +}); + +add_task(async function() { + info( + "Test whether the cache is reflecting existing resources and additional resources" + ); + + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener to get all available resources"); + const availableResources = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => availableResources.push(...resources), + } + ); + + info("Add messages as additional resources"); + const additionalMessages = ["d", "e"]; + await logMessages(tab.linkedBrowser, additionalMessages); + + info("Wait until onAvailable is called expected times"); + const allMessages = [...existingMessages, ...additionalMessages]; + await waitUntil(() => availableResources.length === allMessages.length); + + info("Register second listener to get the cached resources"); + const cachedResources = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + assertContents(availableResources, allMessages); + assertResources(cachedResources, availableResources); + + await targetList.destroy(); + await client.close(); +}); + +add_task(async function() { + info("Test whether the cache is cleared when navigation"); + + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener"); + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: () => {}, + } + ); + + info("Reload the page"); + const onReloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + gBrowser.reloadTab(tab); + await onReloaded; + + info("Register second listener"); + const cachedResources = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + is(cachedResources.length, 0, "The cache in ResourceWatcher is cleared"); + + await targetList.destroy(); + await client.close(); +}); + +add_task(async function() { + info("Test with multiple resource types"); + + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Register first listener to get all available resources"); + const availableResources = []; + await resourceWatcher.watchResources( + [ + ResourceWatcher.TYPES.CONSOLE_MESSAGE, + ResourceWatcher.TYPES.ERROR_MESSAGE, + ], + { + onAvailable: resources => availableResources.push(...resources), + } + ); + + info("Add messages as console message"); + const consoleMessages1 = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, consoleMessages1); + + info("Add message as error message"); + const errorMessages = ["document.doTheImpossible();"]; + await triggerErrors(tab.linkedBrowser, errorMessages); + + info("Add messages as console message again"); + const consoleMessages2 = ["d", "e"]; + await logMessages(tab.linkedBrowser, consoleMessages2); + + info("Wait until the getting all available resources"); + const totalResourceCount = + consoleMessages1.length + errorMessages.length + consoleMessages2.length; + await waitUntil(() => { + return availableResources.length === totalResourceCount; + }); + + info("Register listener to get the cached resources"); + const cachedResources = []; + await resourceWatcher.watchResources( + [ + ResourceWatcher.TYPES.CONSOLE_MESSAGE, + ResourceWatcher.TYPES.ERROR_MESSAGE, + ], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + assertResources(cachedResources, availableResources); + + await targetList.destroy(); + await client.close(); +}); + +add_task(async function() { + info("Test multiple listeners with/without ignoreExistingResources"); + await testIgnoreExistingResources(true); + await testIgnoreExistingResources(false); +}); + +async function testIgnoreExistingResources(isFirstListenerIgnoreExisting) { + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener"); + const cachedResources1 = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources1.push(...resources), + ignoreExistingResources: isFirstListenerIgnoreExisting, + } + ); + + info("Register second listener"); + const cachedResources2 = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources2.push(...resources), + ignoreExistingResources: !isFirstListenerIgnoreExisting, + } + ); + + const cachedResourcesWithFlag = isFirstListenerIgnoreExisting + ? cachedResources1 + : cachedResources2; + const cachedResourcesWithoutFlag = isFirstListenerIgnoreExisting + ? cachedResources2 + : cachedResources1; + + info("Check the existing resources both listeners got"); + assertContents(cachedResourcesWithFlag, []); + assertContents(cachedResourcesWithoutFlag, existingMessages); + + info("Add messages as additional resources"); + const additionalMessages = ["d", "e"]; + await logMessages(tab.linkedBrowser, additionalMessages); + + info("Wait until onAvailable is called expected times"); + await waitUntil( + () => cachedResourcesWithFlag.length === additionalMessages.length + ); + const allMessages = [...existingMessages, ...additionalMessages]; + await waitUntil( + () => cachedResourcesWithoutFlag.length === allMessages.length + ); + + info("Check the resources after adding messages"); + assertContents(cachedResourcesWithFlag, additionalMessages); + assertContents(cachedResourcesWithoutFlag, allMessages); + + await targetList.destroy(); + await client.close(); +} + +add_task(async function() { + info("Test that onAvailable is not called with an empty resources array"); + + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Register first listener to get all available resources"); + const availableResources = []; + let onAvailableCallCount = 0; + const onAvailable = resources => { + ok( + resources.length > 0, + "onAvailable is called with a non empty resources array" + ); + availableResources.push(...resources); + onAvailableCallCount++; + }; + + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + is(availableResources.length, 0, "availableResources array is empty"); + is(onAvailableCallCount, 0, "onAvailable was never called"); + + info("Add messages as console message"); + await logMessages(tab.linkedBrowser, ["expected message"]); + + await waitUntil(() => availableResources.length === 1); + is(availableResources.length, 1, "availableResources array has one item"); + is(onAvailableCallCount, 1, "onAvailable was called only once"); + is( + availableResources[0].message.arguments[0], + "expected message", + "onAvailable was called with the expected resource" + ); + + resourceWatcher.unwatchResources([ResourceWatcher.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); + await targetList.destroy(); + await client.close(); +}); + +function assertContents(resources, expectedMessages) { + is( + resources.length, + expectedMessages.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < expectedMessages.length; i++) { + const resource = resources[i]; + const message = resource.message.arguments[0]; + const expectedMessage = expectedMessages[i]; + is(message, expectedMessage, `The ${i}th content is correct`); + } +} + +function assertResources(resources, expectedResources) { + is( + resources.length, + expectedResources.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const expectedResource = expectedResources[i]; + ok(resource === expectedResource, `The ${i}th resource is correct`); + } +} + +function logMessages(browser, messages) { + return ContentTask.spawn(browser, { messages }, args => { + for (const message of args.messages) { + content.console.log(message); + } + }); +} + +async function triggerErrors(browser, errorScripts) { + for (const errorScript of errorScripts) { + await ContentTask.spawn(browser, errorScript, expr => { + const document = content.document; + const container = document.createElement("script"); + document.body.appendChild(container); + container.textContent = expr; + container.remove(); + }); + } +} diff --git a/devtools/shared/resources/tests/browser_resources_console_messages.js b/devtools/shared/resources/tests/browser_resources_console_messages.js new file mode 100644 index 0000000000..1be72427b1 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_console_messages.js @@ -0,0 +1,460 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around CONSOLE_MESSAGE +// +// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_cached_messages.html +// And now more. Once we remove the console actor's startListeners in favor of watcher class +// We could remove that other old test. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const IFRAME_URL = URL_ROOT_ORG_SSL + "fission_iframe.html"; + +add_task(async function() { + info("Execute test in top level document"); + await testTabConsoleMessagesResources(false); + await testTabConsoleMessagesResourcesWithIgnoreExistingResources(false); + + info("Execute test in an iframe document, possibly remote with fission"); + await testTabConsoleMessagesResources(true); + await testTabConsoleMessagesResourcesWithIgnoreExistingResources(true); +}); + +async function testTabConsoleMessagesResources(executeInIframe) { + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info( + "Log some messages *before* calling ResourceWatcher.watchResources in order to " + + "assert the behavior of already existing messages." + ); + await logExistingMessages(tab.linkedBrowser, executeInIframe); + + const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL; + + let runtimeDoneResolve; + const expectedExistingCalls = getExpectedExistingConsoleCalls( + targetDocumentUrl + ); + const expectedRuntimeCalls = getExpectedRuntimeConsoleCalls( + targetDocumentUrl + ); + const onRuntimeDone = new Promise(resolve => (runtimeDoneResolve = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + if (resource.message.arguments?.[0] === "[WORKER] started") { + // XXX Ignore message from workers as we can't know when they're logged, and we + // have a dedicated test for them (browser_resources_console_messages_workers.js). + continue; + } + + is( + resource.resourceType, + ResourceWatcher.TYPES.CONSOLE_MESSAGE, + "Received a message" + ); + ok(resource.message, "message is wrapped into a message attribute"); + const expected = (expectedExistingCalls.length > 0 + ? expectedExistingCalls + : expectedRuntimeCalls + ).shift(); + checkConsoleAPICall(resource.message, expected); + if (expectedRuntimeCalls.length == 0) { + runtimeDoneResolve(); + } + } + }; + + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + is( + expectedExistingCalls.length, + 0, + "Got the expected number of existing messages" + ); + + info( + "Now log messages *after* the call to ResourceWatcher.watchResources and after having received all existing messages" + ); + await logRuntimeMessages(tab.linkedBrowser, executeInIframe); + + info("Waiting for all runtime messages"); + await onRuntimeDone; + + is( + expectedRuntimeCalls.length, + 0, + "Got the expected number of runtime messages" + ); + + targetList.destroy(); + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +} + +async function testTabConsoleMessagesResourcesWithIgnoreExistingResources( + executeInIframe +) { + info("Test ignoreExistingResources option for console messages"); + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info( + "Check whether onAvailable will not be called with existing console messages" + ); + await logExistingMessages(tab.linkedBrowser, executeInIframe); + + const availableResources = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => availableResources.push(...resources), + ignoreExistingResources: true, + } + ); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing console messages" + ); + + info( + "Check whether onAvailable will be called with the future console messages" + ); + await logRuntimeMessages(tab.linkedBrowser, executeInIframe); + const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL; + const expectedRuntimeConsoleCalls = getExpectedRuntimeConsoleCalls( + targetDocumentUrl + ); + await waitUntil( + () => availableResources.length === expectedRuntimeConsoleCalls.length + ); + const expectedTargetFront = + executeInIframe && isFissionEnabled() + ? targetList + .getAllTargets([targetList.TYPES.FRAME]) + .find(target => target.url == IFRAME_URL) + : targetList.targetFront; + for (let i = 0; i < expectedRuntimeConsoleCalls.length; i++) { + const { message, targetFront } = availableResources[i]; + is( + targetFront, + expectedTargetFront, + "The targetFront property is the expected one" + ); + const expected = expectedRuntimeConsoleCalls[i]; + checkConsoleAPICall(message, expected); + } + + await targetList.destroy(); + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +} + +async function logExistingMessages(browser, executeInIframe) { + let browsingContext = browser.browsingContext; + if (executeInIframe) { + browsingContext = await SpecialPowers.spawn( + browser, + [], + function frameScript() { + return content.document.querySelector("iframe").browsingContext; + } + ); + } + return evalInBrowsingContext(browsingContext, function pageScript() { + console.log("foobarBaz-log", undefined); + console.info("foobarBaz-info", null); + console.warn("foobarBaz-warn", document.body); + }); +} + +/** + * Helper function similar to spawn, but instead of executing the script + * as a Frame Script, with privileges and including test harness in stacktraces, + * execute the script as a regular page script, without privileges and without any + * preceding stack. + * + * @param {BrowsingContext} The browsing context into which the script should be evaluated + * @param {Function|String} The JS to execute in the browsing context + * + * @return {Promise} Which resolves once the JS is done executing in the page + */ +function evalInBrowsingContext(browsingContext, script) { + return SpecialPowers.spawn(browsingContext, [String(script)], expr => { + const document = content.document; + const scriptEl = document.createElement("script"); + document.body.appendChild(scriptEl); + // Force the immediate execution of the stringified JS function passed in `expr` + scriptEl.textContent = "new " + expr; + scriptEl.remove(); + }); +} + +// For both existing and runtime messages, we execute console API +// from a page script evaluated via evalInBrowsingContext. +// Records here the function used to execute the script in the page. +const EXPECTED_FUNCTION_NAME = "pageScript"; + +const NUMBER_REGEX = /^\d+$/; + +function getExpectedExistingConsoleCalls(documentFilename) { + return [ + { + level: "log", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-log", { type: "undefined" }], + }, + { + level: "info", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-info", { type: "null" }], + }, + { + level: "warn", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }], + }, + ]; +} + +const longString = new Array(DevToolsServer.LONG_STRING_LENGTH + 2).join("a"); +function getExpectedRuntimeConsoleCalls(documentFilename) { + const defaultStackFrames = [ + // This is the usage of "new " + expr from `evalInBrowsingContext` + { + filename: documentFilename, + lineNumber: 1, + columnNumber: NUMBER_REGEX, + }, + ]; + + return [ + { + level: "log", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-log", { type: "undefined" }], + }, + { + level: "log", + arguments: ["Float from not a number: NaN"], + }, + { + level: "log", + arguments: ["Float from string: 1.200000"], + }, + { + level: "log", + arguments: ["Float from number: 1.300000"], + }, + { + level: "info", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-info", { type: "null" }], + }, + { + level: "warn", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }], + }, + { + level: "debug", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: [{ type: "null" }], + }, + { + level: "trace", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + stacktrace: [ + { + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + }, + ...defaultStackFrames, + ], + }, + { + level: "dir", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: [ + { + type: "object", + actor: /[a-z]/, + class: "HTMLDocument", + }, + { + type: "object", + actor: /[a-z]/, + class: "Location", + }, + ], + }, + { + level: "log", + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + timeStamp: NUMBER_REGEX, + arguments: [ + "foo", + { + type: "longString", + initial: longString.substring( + 0, + DevToolsServer.LONG_STRING_INITIAL_LENGTH + ), + length: longString.length, + actor: /[a-z]/, + }, + ], + }, + { + level: "error", + filename: documentFilename, + functionName: "fromAsmJS", + timeStamp: NUMBER_REGEX, + arguments: ["foobarBaz-asmjs-error", { type: "undefined" }], + + stacktrace: [ + { + filename: documentFilename, + functionName: "fromAsmJS", + }, + { + filename: documentFilename, + functionName: "inAsmJS2", + }, + { + filename: documentFilename, + functionName: "inAsmJS1", + }, + { + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + }, + ...defaultStackFrames, + ], + }, + { + level: "log", + filename: gTestPath, + functionName: "frameScript", + timeStamp: NUMBER_REGEX, + arguments: [ + { + type: "object", + actor: /[a-z]/, + class: "Restricted", + }, + ], + }, + ]; +} + +async function logRuntimeMessages(browser, executeInIframe) { + let browsingContext = browser.browsingContext; + if (executeInIframe) { + browsingContext = await SpecialPowers.spawn( + browser, + [], + function frameScript() { + return content.document.querySelector("iframe").browsingContext; + } + ); + } + // First inject LONG_STRING_LENGTH in global scope it order to easily use it after + await evalInBrowsingContext( + browsingContext, + `function () {window.LONG_STRING_LENGTH = ${DevToolsServer.LONG_STRING_LENGTH};}` + ); + await evalInBrowsingContext(browsingContext, function pageScript() { + const _longString = new Array(window.LONG_STRING_LENGTH + 2).join("a"); + + console.log("foobarBaz-log", undefined); + + console.log("Float from not a number: %f", "foo"); + console.log("Float from string: %f", "1.2"); + console.log("Float from number: %f", 1.3); + + console.info("foobarBaz-info", null); + console.warn("foobarBaz-warn", document.documentElement); + console.debug(null); + console.trace(); + console.dir(document, location); + console.log("foo", _longString); + + function fromAsmJS() { + console.error("foobarBaz-asmjs-error", undefined); + } + + (function(global, foreign) { + "use asm"; + function inAsmJS2() { + foreign.fromAsmJS(); + } + function inAsmJS1() { + inAsmJS2(); + } + return inAsmJS1; + })(null, { fromAsmJS: fromAsmJS })(); + }); + await SpecialPowers.spawn(browsingContext, [], function frameScript() { + const sandbox = new Cu.Sandbox(null, { invisibleToDebugger: true }); + const sandboxObj = sandbox.eval("new Object"); + content.console.log(sandboxObj); + }); +} + +// Copied from devtools/shared/webconsole/test/chrome/common.js +function checkConsoleAPICall(call, expected) { + is( + call.arguments?.length || 0, + expected.arguments?.length || 0, + "number of arguments" + ); + + checkObject(call, expected); +} diff --git a/devtools/shared/resources/tests/browser_resources_console_messages_workers.js b/devtools/shared/resources/tests/browser_resources_console_messages_workers.js new file mode 100644 index 0000000000..a31388bc32 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_console_messages_workers.js @@ -0,0 +1,246 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around CONSOLE_MESSAGE in workers + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const WORKER_FILE = "test_worker.js"; +const IFRAME_FILE = `${URL_ROOT_ORG_SSL}fission_iframe.html`; + +add_task(async function() { + // Set the following pref to false as it's the one that enables direct connection + // to the worker targets + await pushPref("dom.worker.console.dispatch_events_to_main_thread", false); + + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab, + { listenForWorkers: true } + ); + + info("Wait for the workers (from the main page and the iframe) to be ready"); + const targets = []; + await new Promise(resolve => { + const onAvailable = async ({ targetFront }) => { + targets.push(targetFront); + if (targets.length === 2) { + resolve(); + } + }; + targetList.watchTargets([targetList.TYPES.WORKER], onAvailable); + }); + + // The worker logs a message right when it starts, containing its location, so we can + // assert that we get the logs from the worker spawned in the content page and from the + // worker spawned in the iframe. + info("Check that we receive the cached messages"); + + const resources = []; + const onAvailable = innerResources => { + for (const resource of innerResources) { + // Ignore resources from non worker targets + if (!resource.targetFront.isWorkerTarget) { + continue; + } + + resources.push(resource); + } + }; + + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + + is(resources.length, 2, "Got the expected number of existing messages"); + const startLogFromWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker` + ); + const startLogFromWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe` + ); + + checkStartWorkerLogMessage( + startLogFromWorkerInMainPage, + `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker` + ); + checkStartWorkerLogMessage( + startLogFromWorkerInIframe, + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe` + ); + let messageCount = resources.length; + + info( + "Now log messages *after* the call to ResourceWatcher.watchResources and after having received all existing messages" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.wrappedJSObject.logMessageInWorker("live message from main page"); + + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [], () => { + content.wrappedJSObject.logMessageInWorker("live message from iframe"); + }); + }); + + // Wait until the 2 new logs are available + await waitUntil(() => resources.length === messageCount + 2); + const liveMessageFromWorkerInMainPage = resources.find( + ({ message }) => message.arguments[1] === "live message from main page" + ); + const liveMessageFromWorkerInIframe = resources.find( + ({ message }) => message.arguments[1] === "live message from iframe" + ); + + checkLogInWorkerMessage( + liveMessageFromWorkerInMainPage, + "live message from main page" + ); + + checkLogInWorkerMessage( + liveMessageFromWorkerInIframe, + "live message from iframe" + ); + + // update the current number of resources received + messageCount = resources.length; + + info("Now spawn new workers and log messages in main page and iframe"); + await SpecialPowers.spawn( + tab.linkedBrowser, + [WORKER_FILE], + async workerUrl => { + const spawnedWorker = new content.Worker(`${workerUrl}#spawned-worker`); + spawnedWorker.postMessage({ + type: "log-in-worker", + message: "live message in spawned worker from main page", + }); + + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [workerUrl], async innerWorkerUrl => { + const spawnedWorkerInIframe = new content.Worker( + `${innerWorkerUrl}#spawned-worker-in-iframe` + ); + spawnedWorkerInIframe.postMessage({ + type: "log-in-worker", + message: "live message in spawned worker from iframe", + }); + }); + } + ); + + info( + "Wait until the 4 new logs are available (the ones logged at worker creation + the ones from postMessage" + ); + await waitUntil( + () => resources.length === messageCount + 4, + `Couldn't get the expected number of resources (expected ${messageCount + + 4}, got ${resources.length})` + ); + const startLogFromSpawnedWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker` + ); + const startLogFromSpawnedWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe` + ); + const liveMessageFromSpawnedWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === "live message in spawned worker from main page" + ); + const liveMessageFromSpawnedWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === "live message in spawned worker from iframe" + ); + + checkStartWorkerLogMessage( + startLogFromSpawnedWorkerInMainPage, + `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker` + ); + checkStartWorkerLogMessage( + startLogFromSpawnedWorkerInIframe, + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe` + ); + checkLogInWorkerMessage( + liveMessageFromSpawnedWorkerInMainPage, + "live message in spawned worker from main page" + ); + checkLogInWorkerMessage( + liveMessageFromSpawnedWorkerInIframe, + "live message in spawned worker from iframe" + ); + // update the current number of resources received + messageCount = resources.length; + + info( + "Add a remote iframe on the same origin we already have an iframe and check we get the messages" + ); + await SpecialPowers.spawn( + tab.linkedBrowser, + [IFRAME_FILE], + async iframeUrl => { + const iframe = content.document.createElement("iframe"); + iframe.src = `${iframeUrl}?hashSuffix=in-second-iframe`; + content.document.body.append(iframe); + } + ); + + info("Wait until the new log is available"); + await waitUntil( + () => resources.length === messageCount + 1, + `Couldn't get the expected number of resources (expected ${messageCount + + 1}, got ${resources.length})` + ); + const startLogFromWorkerInSecondIframe = resources[resources.length - 1]; + checkStartWorkerLogMessage( + startLogFromWorkerInSecondIframe, + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-second-iframe` + ); + + targetList.destroy(); + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +function checkStartWorkerLogMessage(resource, expectedUrl) { + const { message } = resource; + const [firstArg, secondArg, thirdArg] = message.arguments; + is(firstArg, "[WORKER] started", "Got the expected first argument"); + is(secondArg, expectedUrl, "expected url was logged"); + is( + thirdArg?._grip?.class, + "DedicatedWorkerGlobalScope", + "the global scope was logged as expected" + ); +} + +function checkLogInWorkerMessage(resource, expectedMessage) { + const { message } = resource; + const [firstArg, secondArg, thirdArg] = message.arguments; + is(firstArg, "[WORKER]", "Got the expected first argument"); + is(secondArg, expectedMessage, "expected message was logged"); + is( + thirdArg?._grip?.class, + "MessageEvent", + "the message event object was logged as expected" + ); +} diff --git a/devtools/shared/resources/tests/browser_resources_css_changes.js b/devtools/shared/resources/tests/browser_resources_css_changes.js new file mode 100644 index 0000000000..d81953e75e --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_css_changes.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around CSS_CHANGE. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +add_task(async function() { + // Open a test tab + const tab = await addTab( + "data:text/html,<body style='color: lime;'>CSS Changes</body>" + ); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + // CSS_CHANGE watcher doesn't record modification made before watching, + // so we have to start watching before doing any DOM mutation. + await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_CHANGE], { + onAvailable: () => {}, + }); + + const { walker } = await targetList.targetFront.getFront("inspector"); + const nodeList = await walker.querySelectorAll(walker.rootNode, "body"); + const body = (await nodeList.items())[0]; + const style = ( + await body.inspectorFront.pageStyle.getApplied(body, { + skipPseudo: false, + }) + )[0]; + + info( + "Check whether ResourceWatcher catches CSS change that fired before starting to watch" + ); + await setProperty(style.rule, 0, "color", "black"); + + const availableResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_CHANGE], { + onAvailable: resources => availableResources.push(...resources), + }); + assertResource( + availableResources[0], + { index: 0, property: "color", value: "black" }, + { index: 0, property: "color", value: "lime" } + ); + + info( + "Check whether ResourceWatcher catches CSS change after the property changed" + ); + await setProperty(style.rule, 0, "background-color", "pink"); + await waitUntil(() => availableResources.length === 2); + assertResource( + availableResources[1], + { index: 0, property: "background-color", value: "pink" }, + { index: 0, property: "color", value: "black" } + ); + + info("Check whether ResourceWatcher catches CSS change of disabling"); + await setPropertyEnabled(style.rule, 0, "background-color", false); + await waitUntil(() => availableResources.length === 3); + assertResource(availableResources[2], null, { + index: 0, + property: "background-color", + value: "pink", + }); + + info("Check whether ResourceWatcher catches CSS change of new property"); + await createProperty(style.rule, 1, "font-size", "100px"); + await waitUntil(() => availableResources.length === 4); + assertResource( + availableResources[3], + { index: 1, property: "font-size", value: "100px" }, + null + ); + + info("Check whether ResourceWatcher sends all resources added in this test"); + const existingResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_CHANGE], { + onAvailable: resources => existingResources.push(...resources), + }); + await waitUntil(() => existingResources.length === 4); + is(availableResources[0], existingResources[0], "1st resource is correct"); + is(availableResources[1], existingResources[1], "2nd resource is correct"); + is(availableResources[2], existingResources[2], "3rd resource is correct"); + is(availableResources[3], existingResources[3], "4th resource is correct"); + + await targetList.destroy(); + await client.close(); +}); + +function assertResource(resource, expectedAddedChange, expectedRemovedChange) { + if (expectedAddedChange) { + is(resource.add.length, 1, "The number of added changes is correct"); + assertChange(resource.add[0], expectedAddedChange); + } else { + is(resource.add, null, "There is no added changes"); + } + + if (expectedRemovedChange) { + is(resource.remove.length, 1, "The number of removed changes is correct"); + assertChange(resource.remove[0], expectedRemovedChange); + } else { + is(resource.remove, null, "There is no removed changes"); + } +} + +function assertChange(change, expected) { + is(change.index, expected.index, "The index of change is correct"); + is(change.property, expected.property, "The property of change is correct"); + is(change.value, expected.value, "The value of change is correct"); +} + +async function setProperty(rule, index, property, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.setProperty(index, property, value, ""); + await modifications.apply(); +} + +async function createProperty(rule, index, property, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.createProperty(index, property, value, "", true); + await modifications.apply(); +} + +async function setPropertyEnabled(rule, index, property, isEnabled) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.setPropertyEnabled(index, property, isEnabled); + await modifications.apply(); +} diff --git a/devtools/shared/resources/tests/browser_resources_css_messages.js b/devtools/shared/resources/tests/browser_resources_css_messages.js new file mode 100644 index 0000000000..0e82bacd57 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_css_messages.js @@ -0,0 +1,202 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around CSS_MESSAGE +// Reproduces the CSS message assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); +const { MESSAGE_CATEGORY } = require("devtools/shared/constants"); + +// Create a simple server so we have a nice sourceName in the resources packets. +const httpServer = createTestHTTPServer(); +httpServer.registerPathHandler(`/test_css_messages.html`, (req, res) => { + res.setStatusLine(req.httpVersion, 200, "OK"); + res.write(`<meta charset=utf8> + <style> + html { + color: bloup; + } + </style>Test CSS Messages`); +}); + +const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_css_messages.html`; + +add_task(async function() { + await testWatchingCssMessages(); + await testWatchingCachedCssMessages(); +}); + +async function testWatchingCssMessages() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // Open a test tab + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + const receivedMessages = []; + const { onAvailable, onAllMessagesReceived } = setupOnAvailableFunction( + targetList, + receivedMessages + ); + await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_MESSAGE], { + onAvailable, + }); + + info( + "Now log CSS warning *after* the call to ResourceWatcher.watchResources and after " + + "having received the existing message" + ); + // We need to wait for the first CSS Warning as it is not a cached message; when we + // start watching, the `cssErrorReportingEnabled` is checked on the target docShell, and + // if it is false, we re-parse the stylesheets to get the messages. + await BrowserTestUtils.waitForCondition(() => receivedMessages.length === 1); + + info("Trigger a CSS Warning"); + triggerCSSWarning(tab); + + info("Waiting for all expected CSS messages to be received"); + await onAllMessagesReceived; + ok(true, "All the expected CSS messages were received"); + + Services.console.reset(); + targetList.destroy(); + await client.close(); +} + +async function testWatchingCachedCssMessages() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // Open a test tab + const tab = await addTab(TEST_URI); + + // By default, the CSS Parser does not emit warnings at all, for performance matter. + // Since we actually want the Parser to emit those messages _before_ we start listening + // for CSS messages, we need to set the cssErrorReportingEnabled flag on the docShell. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function() { + content.docShell.cssErrorReportingEnabled = true; + }); + + // Setting the docShell flag only indicates to the Parser that from now on, it should + // emit warnings. But it does not automatically emit warnings for the existing CSS + // errors in the stylesheets. So here we reload the tab, which will make the Parser + // parse the stylesheets again, this time emitting warnings. + const loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + tab.linkedBrowser.reload(); + // wait for the tab to be fully loaded + await loaded; + // and trigger more CSS warnings + await triggerCSSWarning(tab); + + // At this point, all messages should be in the ConsoleService cache, and we can begin + // to watch and check that we do retrieve those messages. + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + const receivedMessages = []; + const { onAvailable } = setupOnAvailableFunction( + targetList, + receivedMessages + ); + await resourceWatcher.watchResources([ResourceWatcher.TYPES.CSS_MESSAGE], { + onAvailable, + }); + is(receivedMessages.length, 3, "Cached messages were retrieved as expected"); + + Services.console.reset(); + targetList.destroy(); + await client.close(); +} + +function setupOnAvailableFunction(targetList, receivedMessages) { + // The expected messages are the CSS warnings: + // - one for the rule in the style element + // - two for the JS modified style we're doing in the test. + const expectedMessages = [ + { + pageError: { + errorMessage: /Expected color but found ‘bloup’/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: /^\d+$/, + error: false, + warning: true, + }, + cssSelectors: "html", + }, + { + pageError: { + errorMessage: /Error in parsing value for ‘width’/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: /^\d+$/, + error: false, + warning: true, + }, + }, + { + pageError: { + errorMessage: /Error in parsing value for ‘height’/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: /^\d+$/, + error: false, + warning: true, + }, + }, + ]; + + let done; + const onAllMessagesReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + const { pageError } = resource; + + is( + resource.targetFront, + targetList.targetFront, + "The targetFront property is the expected one" + ); + + if (!pageError.sourceName.includes("test_css_messages")) { + info(`Ignore error from unknown source: "${pageError.sourceName}"`); + continue; + } + + const index = receivedMessages.length; + receivedMessages.push(pageError); + + info( + `checking received css message #${index}: ${pageError.errorMessage}` + ); + ok(pageError, "The resource has a pageError attribute"); + checkObject(resource, expectedMessages[index]); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + return { onAvailable, onAllMessagesReceived }; +} + +/** + * Sets invalid values for width and height on the document's body style attribute. + */ +function triggerCSSWarning(tab) { + return ContentTask.spawn(tab.linkedBrowser, null, function frameScript() { + content.document.body.style.width = "red"; + content.document.body.style.height = "blue"; + }); +} diff --git a/devtools/shared/resources/tests/browser_resources_document_events.js b/devtools/shared/resources/tests/browser_resources_document_events.js new file mode 100644 index 0000000000..bf6a66ed40 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_document_events.js @@ -0,0 +1,143 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around DOCUMENT_EVENT + +const { TargetList } = require("devtools/shared/resources/target-list"); +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +add_task(async function() { + await testDocumentEventResources(); + await testDocumentEventResourcesWithIgnoreExistingResources(); +}); + +async function testDocumentEventResources() { + info("Test ResourceWatcher for DOCUMENT_EVENT"); + + // Open a test tab + const tab = await addTab("data:text/html,Document Events"); + + // Create a TargetList for the test tab + const client = await createLocalClient(); + const descriptor = await client.mainRoot.getTab({ tab }); + const target = await descriptor.getTarget(); + const targetList = new TargetList(client.mainRoot, target); + await targetList.startListening(); + + // Activate ResourceWatcher + const listener = new ResourceListener(); + const resourceWatcher = new ResourceWatcher(targetList); + + 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 resourceWatcher.watchResources([ResourceWatcher.TYPES.DOCUMENT_EVENT], { + onAvailable: parameters => listener.dispatch(parameters), + }); + await assertPromises(onLoadingAtInit, onInteractiveAtInit, onCompleteAtInit); + ok( + true, + "Document events are fired even when the document was already loaded" + ); + + info("Check whether the document events are fired correctly when reloading"); + const onLoadingAtReloaded = listener.once("dom-loading"); + const onInteractiveAtReloaded = listener.once("dom-interactive"); + const onCompleteAtReloaded = listener.once("dom-complete"); + gBrowser.reloadTab(tab); + await assertPromises( + onLoadingAtReloaded, + onInteractiveAtReloaded, + onCompleteAtReloaded + ); + ok(true, "Document events are fired after reloading"); + + await targetList.destroy(); + await client.close(); +} + +async function testDocumentEventResourcesWithIgnoreExistingResources() { + info("Test ignoreExistingResources option for DOCUMENT_EVENT"); + + const tab = await addTab("data:text/html,Document Events"); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Check whether the existing document events will not be fired"); + const documentEvents = []; + await resourceWatcher.watchResources([ResourceWatcher.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"); + gBrowser.reloadTab(tab); + info("Wait for dom-loading, dom-interactive and dom-complete events"); + await waitUntil(() => documentEvents.length === 3); + assertEvents(...documentEvents); + + await targetList.destroy(); + await client.close(); +} + +async function assertPromises(onLoading, onInteractive, onComplete) { + const loadingEvent = await onLoading; + const interactiveEvent = await onInteractive; + const completeEvent = await onComplete; + assertEvents(loadingEvent, interactiveEvent, completeEvent); +} + +function assertEvents(loadingEvent, interactiveEvent, completeEvent) { + is( + typeof loadingEvent.time, + "number", + "Type of time attribute for loading event is correct" + ); + is( + typeof interactiveEvent.time, + "number", + "Type of time attribute for interactive event is correct" + ); + is( + typeof completeEvent.time, + "number", + "Type of time attribute for complete event is correct" + ); + + ok( + loadingEvent.time < interactiveEvent.time, + "Timestamp for interactive event is greater than loading event" + ); + ok( + interactiveEvent.time < completeEvent.time, + "Timestamp for complete event is greater than interactive event" + ); +} + +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)); + } +} diff --git a/devtools/shared/resources/tests/browser_resources_error_messages.js b/devtools/shared/resources/tests/browser_resources_error_messages.js new file mode 100644 index 0000000000..959a97e87e --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_error_messages.js @@ -0,0 +1,614 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around ERROR_MESSAGE +// Reproduces assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +// Create a simple server so we have a nice sourceName in the resources packets. +const httpServer = createTestHTTPServer(); +httpServer.registerPathHandler(`/test_page_errors.html`, (req, res) => { + res.setStatusLine(req.httpVersion, 200, "OK"); + res.write(`<meta charset=utf8>Test Error Messages`); +}); + +const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_page_errors.html`; + +add_task(async function() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + await testErrorMessagesResources(); + await testErrorMessagesResourcesWithIgnoreExistingResources(); +}); + +async function testErrorMessagesResources() { + // Open a test tab + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + const receivedMessages = []; + // The expected messages are the errors, twice (once for cached messages, once for live messages) + const expectedMessages = Array.from(expectedPageErrors.values()).concat( + Array.from(expectedPageErrors.values()) + ); + + info( + "Log some errors *before* calling ResourceWatcher.watchResources in order to assert" + + " the behavior of already existing messages." + ); + await triggerErrors(tab); + + let done; + const onAllErrorReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + const { pageError } = resource; + + is( + resource.targetFront, + targetList.targetFront, + "The targetFront property is the expected one" + ); + + if (!pageError.sourceName.includes("test_page_errors")) { + info(`Ignore error from unknown source: "${pageError.sourceName}"`); + continue; + } + + const index = receivedMessages.length; + receivedMessages.push(pageError); + + info(`checking received page error #${index}: ${pageError.errorMessage}`); + ok(pageError, "The resource has a pageError attribute"); + checkPageErrorResource(pageError, expectedMessages[index]); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + + await resourceWatcher.watchResources([ResourceWatcher.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + info( + "Now log errors *after* the call to ResourceWatcher.watchResources and after having" + + " received all existing messages" + ); + await BrowserTestUtils.waitForCondition( + () => receivedMessages.length === expectedPageErrors.size + ); + await triggerErrors(tab); + + info("Waiting for all expected errors to be received"); + await onAllErrorReceived; + ok(true, "All the expected errors were received"); + + Services.console.reset(); + targetList.destroy(); + await client.close(); +} + +async function testErrorMessagesResourcesWithIgnoreExistingResources() { + info("Test ignoreExistingResources option for ERROR_MESSAGE"); + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info( + "Check whether onAvailable will not be called with existing error messages" + ); + await triggerErrors(tab); + + const availableResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.ERROR_MESSAGE], { + onAvailable: resources => availableResources.push(...resources), + ignoreExistingResources: true, + }); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing error messages" + ); + + info( + "Check whether onAvailable will be called with the future error messages" + ); + await triggerErrors(tab); + + const expectedMessages = Array.from(expectedPageErrors.values()); + await waitUntil(() => availableResources.length === expectedMessages.length); + for (let i = 0; i < expectedMessages.length; i++) { + const { pageError } = availableResources[i]; + const expected = expectedMessages[i]; + checkPageErrorResource(pageError, expected); + } + + Services.console.reset(); + await targetList.destroy(); + await client.close(); +} + +/** + * Triggers all the errors in the content page. + */ +async function triggerErrors(tab) { + for (const [expression, expected] of expectedPageErrors.entries()) { + if ( + !expected[noUncaughtException] && + !Services.appinfo.browserTabsRemoteAutostart + ) { + expectUncaughtException(); + } + + await ContentTask.spawn(tab.linkedBrowser, expression, function frameScript( + expr + ) { + const document = content.document; + const scriptEl = document.createElement("script"); + scriptEl.textContent = expr; + document.body.appendChild(scriptEl); + }); + + if (expected.isPromiseRejection) { + // Wait a bit after an uncaught promise rejection error, as they are not emitted + // right away. + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(res => setTimeout(res, 10)); + } + } +} + +function checkPageErrorResource(pageErrorResource, expected) { + // Let's remove test harness related frames in stacktrace + const clonedPageErrorResource = { ...pageErrorResource }; + if (clonedPageErrorResource.stacktrace) { + const index = clonedPageErrorResource.stacktrace.findIndex(frame => + frame.filename.startsWith("resource://testing-common/content-task.js") + ); + if (index > -1) { + clonedPageErrorResource.stacktrace = clonedPageErrorResource.stacktrace.slice( + 0, + index + ); + } + } + checkObject(clonedPageErrorResource, expected); +} + +const noUncaughtException = Symbol(); +const NUMBER_REGEX = /^\d+$/; + +const mdnUrl = path => + `https://developer.mozilla.org/${path}?utm_source=mozilla&utm_medium=firefox-console-errors&utm_campaign=default`; + +const expectedPageErrors = new Map([ + [ + "document.doTheImpossible();", + { + errorMessage: /doTheImpossible/, + errorMessageName: undefined, + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Not_a_function" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 10, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "(42).toString(0);", + { + errorMessage: /radix/, + errorMessageName: "JSMSG_BAD_RADIX", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Bad_radix"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 6, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "'use strict'; (Object.freeze({name: 'Elsa', score: 157})).score = 0;", + { + errorMessage: /read.only/, + errorMessageName: "JSMSG_READ_ONLY", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 23, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "([]).length = -1", + { + errorMessage: /array length/, + errorMessageName: "JSMSG_BAD_ARRAY_LENGTH", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Invalid_array_length" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 2, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "'abc'.repeat(-1);", + { + errorMessage: /repeat count.*non-negative/, + errorMessageName: "JSMSG_NEGATIVE_REPETITION_COUNT", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Negative_repetition_count" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: "self-hosted", + sourceId: null, + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + functionName: "repeat", + }, + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "'a'.repeat(2e28);", + { + errorMessage: /repeat count.*less than infinity/, + errorMessageName: "JSMSG_RESULTING_STRING_TOO_LARGE", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Resulting_string_too_large" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: "self-hosted", + sourceId: null, + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + functionName: "repeat", + }, + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 5, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "77.1234.toExponential(-1);", + { + errorMessage: /out of range/, + errorMessageName: "JSMSG_PRECISION_RANGE", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Precision_range" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 9, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "function a() { return; 1 + 1; }", + { + errorMessage: /unreachable code/, + errorMessageName: "JSMSG_STMT_AFTER_RETURN", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: false, + warning: true, + info: false, + sourceId: null, + lineText: "function a() { return; 1 + 1; }", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Stmt_after_return" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: null, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "{let a, a;}", + { + errorMessage: /redeclaration of/, + errorMessageName: "JSMSG_REDECLARED_VAR", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + sourceId: null, + lineText: "{let a, a;}", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl( + "docs/Web/JavaScript/Reference/Errors/Redeclared_parameter" + ), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [], + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + notes: [ + { + messageBody: /Previously declared at line/, + frame: { + source: /test_page_errors/, + }, + }, + ], + }, + ], + [ + `var error = new TypeError("abc"); + error.name = "MyError"; + error.message = "here"; + throw error`, + { + errorMessage: /MyError: here/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 13, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + "DOMTokenList.prototype.contains.call([])", + { + errorMessage: /does not implement interface/, + errorMessageName: "MSG_METHOD_THIS_DOES_NOT_IMPLEMENT_INTERFACE", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 33, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + ` + function promiseThrow() { + var error2 = new TypeError("abc"); + error2.name = "MyPromiseError"; + error2.message = "here2"; + return Promise.reject(error2); + } + promiseThrow()`, + { + errorMessage: /MyPromiseError: here2/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"), + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + sourceId: null, + lineNumber: 6, + columnNumber: 24, + functionName: "promiseThrow", + }, + { + filename: /test_page_errors\.html/, + sourceId: null, + lineNumber: 8, + columnNumber: 7, + functionName: null, + }, + ], + notes: null, + chromeContext: false, + isPromiseRejection: true, + isForwardedFromContentProcess: false, + [noUncaughtException]: true, + }, + ], +]); diff --git a/devtools/shared/resources/tests/browser_resources_getAllResources.js b/devtools/shared/resources/tests/browser_resources_getAllResources.js new file mode 100644 index 0000000000..c22f6e0b52 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_getAllResources.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test getAllResources function of the ResourceWatcher. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const TEST_URI = "data:text/html;charset=utf-8,getAllResources test"; + +add_task(async function() { + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Check the resources gotten from getAllResources at initial"); + is( + resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE) + .length, + 0, + "There is no resources at initial" + ); + + info( + "Start to watch the available resources in order to compare with resources gotten from getAllResources" + ); + const availableResources = []; + const onAvailable = resources => availableResources.push(...resources); + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + + info("Check the resources after some resources are available"); + const messages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, messages); + await waitUntil(() => availableResources.length >= messages.length); + assertResources( + resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE), + availableResources + ); + assertResources( + resourceWatcher.getAllResources(ResourceWatcher.TYPES.STYLESHEET), + [] + ); + + info("Check the resources after reloading"); + const onReloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + gBrowser.reloadTab(tab); + await onReloaded; + assertResources( + resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE), + [] + ); + + info("Append some resources again to test unwatching"); + await logMessages(tab.linkedBrowser, messages); + await waitUntil( + () => + resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE) + .length === messages.length + ); + + info("Check the resources after unwatching"); + resourceWatcher.unwatchResources([ResourceWatcher.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); + assertResources( + resourceWatcher.getAllResources(ResourceWatcher.TYPES.CONSOLE_MESSAGE), + [] + ); + + await targetList.destroy(); + await client.close(); +}); + +function assertResources(resources, expectedResources) { + is( + resources.length, + expectedResources.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const expectedResource = expectedResources[i]; + ok(resource === expectedResource, `The ${i}th resource is correct`); + } +} + +function logMessages(browser, messages) { + return ContentTask.spawn(browser, { messages }, args => { + for (const message of args.messages) { + content.console.log(message); + } + }); +} diff --git a/devtools/shared/resources/tests/browser_resources_network_event_stacktraces.js b/devtools/shared/resources/tests/browser_resources_network_event_stacktraces.js new file mode 100644 index 0000000000..23500b231c --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_network_event_stacktraces.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around NETWORK_EVENT_STACKTRACE + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const TEST_URI = `${URL_ROOT_SSL}network_document.html`; + +const REQUEST_STUB = { + code: `await fetch("/request_post_0.html", { method: "POST" });`, + expected: { + stacktraceAvailable: true, + lastFrame: { + filename: + "https://example.com/browser/devtools/shared/resources/tests/network_document.html", + lineNumber: 1, + columnNumber: 40, + functionName: "triggerRequest", + asyncCause: null, + }, + }, +}; + +add_task(async function() { + info("Test network stacktraces events"); + const tab = await addTab(TEST_URI); + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + const networkEvents = new Map(); + const stackTraces = new Map(); + + function onResourceAvailable(resources) { + for (const resource of resources) { + if ( + resource.resourceType === ResourceWatcher.TYPES.NETWORK_EVENT_STACKTRACE + ) { + ok( + !networkEvents.has(resource.resourceId), + "The network event does not exist" + ); + + is( + resource.stacktraceAvailable, + REQUEST_STUB.expected.stacktraceAvailable, + "The stacktrace is available" + ); + is( + JSON.stringify(resource.lastFrame), + JSON.stringify(REQUEST_STUB.expected.lastFrame), + "The last frame of the stacktrace is available" + ); + + stackTraces.set(resource.resourceId, true); + return; + } + + if (resource.resourceType === ResourceWatcher.TYPES.NETWORK_EVENT) { + ok( + stackTraces.has(resource.stacktraceResourceId), + "The stack trace does exists" + ); + + networkEvents.set(resource.resourceId, true); + } + } + } + + function onResourceUpdated() {} + + await resourceWatcher.watchResources( + [ + ResourceWatcher.TYPES.NETWORK_EVENT_STACKTRACE, + ResourceWatcher.TYPES.NETWORK_EVENT, + ], + { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + } + ); + + await triggerNetworkRequests(tab.linkedBrowser, [REQUEST_STUB.code]); + + resourceWatcher.unwatchResources( + [ + ResourceWatcher.TYPES.NETWORK_EVENT_STACKTRACE, + ResourceWatcher.TYPES.NETWORK_EVENT, + ], + { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + } + ); + + await targetList.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/devtools/shared/resources/tests/browser_resources_network_events.js b/devtools/shared/resources/tests/browser_resources_network_events.js new file mode 100644 index 0000000000..81113779bf --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_network_events.js @@ -0,0 +1,258 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around NETWORK_EVENT + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const EXAMPLE_DOMAIN = "https://example.com/"; +const TEST_URI = `${URL_ROOT_SSL}network_document.html`; + +add_task(async function() { + info("Test network events"); + await testNetworkEventResourcesWithExistingResources(); + await testNetworkEventResourcesWithoutExistingResources(); +}); + +async function testNetworkEventResourcesWithExistingResources() { + info(`Tests for network event resources with the existing resources`); + await testNetworkEventResources({ + ignoreExistingResources: false, + // 1 available event fired, for the existing resource in the cache. + // 1 available event fired, when live request is created. + totalExpectedOnAvailableCounts: 2, + // 1 update events fired, when live request is updated. + totalExpectedOnUpdatedCounts: 1, + expectedResourcesOnAvailable: { + [`${EXAMPLE_DOMAIN}cached_post.html`]: { + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT, + method: "POST", + }, + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + expectedResourcesOnUpdated: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + }); +} + +async function testNetworkEventResourcesWithoutExistingResources() { + info(`Tests for network event resources without the existing resources`); + await testNetworkEventResources({ + ignoreExistingResources: true, + // 1 available event fired, when live request is created. + totalExpectedOnAvailableCounts: 1, + // 1 update events fired, when live request is updated. + totalExpectedOnUpdatedCounts: 1, + expectedResourcesOnAvailable: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + expectedResourcesOnUpdated: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceWatcher.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + }); +} + +async function testNetworkEventResources(options) { + const tab = await addTab(TEST_URI); + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info( + `Trigger some network requests *before* calling ResourceWatcher.watchResources + in order to assert the behavior of already existing network events.` + ); + + let onResourceAvailable = () => {}; + let onResourceUpdated = () => {}; + + // Lets make sure there is already a network event resource in the cache. + const waitOnRequestForResourceWatcherCache = new Promise(resolve => { + onResourceAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + ResourceWatcher.TYPES.NETWORK_EVENT, + "Received a network event resource" + ); + } + }; + + onResourceUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + ResourceWatcher.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + resolve(); + } + }; + + resourceWatcher + .watchResources([ResourceWatcher.TYPES.NETWORK_EVENT], { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + }) + .then(() => { + // We can only trigger the requests once `watchResources` settles, otherwise the + // thread might be paused. + triggerNetworkRequests(tab.linkedBrowser, [cachedRequest]); + }); + }); + + await waitOnRequestForResourceWatcherCache; + + const actualResourcesOnAvailable = {}; + const actualResourcesOnUpdated = {}; + + let { + totalExpectedOnAvailableCounts, + totalExpectedOnUpdatedCounts, + expectedResourcesOnAvailable, + expectedResourcesOnUpdated, + + ignoreExistingResources, + } = options; + + const waitForAllExpectedOnAvailableEvents = waitUntil( + () => totalExpectedOnAvailableCounts == 0 + ); + const waitForAllExpectedOnUpdatedEvents = waitUntil( + () => totalExpectedOnUpdatedCounts == 0 + ); + + const onAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + ResourceWatcher.TYPES.NETWORK_EVENT, + "Received a network event resource" + ); + actualResourcesOnAvailable[resource.url] = { + resourceId: resource.resourceId, + resourceType: resource.resourceType, + method: resource.method, + }; + totalExpectedOnAvailableCounts--; + } + }; + + const onUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + ResourceWatcher.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + actualResourcesOnUpdated[resource.url] = { + resourceId: resource.resourceId, + resourceType: resource.resourceType, + method: resource.method, + }; + totalExpectedOnUpdatedCounts--; + } + }; + + await resourceWatcher.watchResources([ResourceWatcher.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + ignoreExistingResources, + }); + + info( + `Trigger the rest of the requests *after* calling ResourceWatcher.watchResources + in order to assert the behavior of live network events.` + ); + await triggerNetworkRequests(tab.linkedBrowser, [liveRequest]); + + await Promise.all([ + waitForAllExpectedOnAvailableEvents, + waitForAllExpectedOnUpdatedEvents, + ]); + + info("Check the resources on available"); + is( + Object.keys(actualResourcesOnAvailable).length, + Object.keys(expectedResourcesOnAvailable).length, + "Got the expected number of network events fired onAvailable" + ); + + // assert that the resourceId for the the available and updated events match + is( + actualResourcesOnAvailable[`${EXAMPLE_DOMAIN}live_get.html`].resourceId, + actualResourcesOnUpdated[`${EXAMPLE_DOMAIN}live_get.html`].resourceId, + "The resource id's are the same" + ); + + // assert the resources emitted when the network event is created + for (const key in expectedResourcesOnAvailable) { + const expected = expectedResourcesOnAvailable[key]; + const actual = actualResourcesOnAvailable[key]; + assertResources(actual, expected); + } + + info("Check the resources on updated"); + + is( + Object.keys(actualResourcesOnUpdated).length, + Object.keys(expectedResourcesOnUpdated).length, + "Got the expected number of network events fired onUpdated" + ); + + // assert the resources emitted when the network event is updated + for (const key in expectedResourcesOnUpdated) { + const expected = expectedResourcesOnUpdated[key]; + const actual = actualResourcesOnUpdated[key]; + assertResources(actual, expected); + } + + await resourceWatcher.unwatchResources( + [ResourceWatcher.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + ignoreExistingResources, + } + ); + + await resourceWatcher.unwatchResources( + [ResourceWatcher.TYPES.NETWORK_EVENT], + { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + } + ); + await targetList.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +} + +function assertResources(actual, expected) { + is( + actual.resourceType, + expected.resourceType, + "The resource type is correct" + ); + is(actual.method, expected.method, "The method is correct"); +} + +const cachedRequest = `await fetch("/cached_post.html", { method: "POST" });`; +const liveRequest = `await fetch("/live_get.html", { method: "GET" });`; diff --git a/devtools/shared/resources/tests/browser_resources_platform_messages.js b/devtools/shared/resources/tests/browser_resources_platform_messages.js new file mode 100644 index 0000000000..324b92f771 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_platform_messages.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around PLATFORM_MESSAGE +// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_nsiconsolemessage.html + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +add_task(async function() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + await testPlatformMessagesResources(); + await testPlatformMessagesResourcesWithIgnoreExistingResources(); +}); + +async function testPlatformMessagesResources() { + const { + client, + resourceWatcher, + targetList, + } = await initMultiProcessResourceWatcher(); + + const expectedMessages = [ + "This is a cached message", + "This is another cached message", + "This is a live message", + "This is another live message", + ]; + const receivedMessages = []; + + info( + "Log some messages *before* calling ResourceWatcher.watchResources in order to assert the behavior of already existing messages." + ); + Services.console.logStringMessage(expectedMessages[0]); + Services.console.logStringMessage(expectedMessages[1]); + + let done; + const onAllMessagesReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + if (!expectedMessages.includes(resource.message)) { + continue; + } + + is( + resource.targetFront, + targetList.targetFront, + "The targetFront property is the expected one" + ); + + receivedMessages.push(resource.message); + is( + resource.message, + expectedMessages[receivedMessages.length - 1], + `Received the expected «${resource.message}» message, in the expected order` + ); + + ok( + resource.timeStamp.toString().match(/^\d+$/), + "The resource has a timeStamp property" + ); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.PLATFORM_MESSAGE], + { + onAvailable, + } + ); + + info( + "Now log messages *after* the call to ResourceWatcher.watchResources and after having received all existing messages" + ); + Services.console.logStringMessage(expectedMessages[2]); + Services.console.logStringMessage(expectedMessages[3]); + + info("Waiting for all expected messages to be received"); + await onAllMessagesReceived; + ok(true, "All the expected messages were received"); + + Services.console.reset(); + targetList.destroy(); + await client.close(); +} + +async function testPlatformMessagesResourcesWithIgnoreExistingResources() { + const { + client, + resourceWatcher, + targetList, + } = await initMultiProcessResourceWatcher(); + + info( + "Check whether onAvailable will not be called with existing platform messages" + ); + const expectedMessages = ["This is 1st message", "This is 2nd message"]; + Services.console.logStringMessage(expectedMessages[0]); + Services.console.logStringMessage(expectedMessages[1]); + + const availableResources = []; + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.PLATFORM_MESSAGE], + { + onAvailable: resources => { + for (const resource of resources) { + if (!expectedMessages.includes(resource.message)) { + continue; + } + + availableResources.push(resource); + } + }, + ignoreExistingResources: true, + } + ); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing platform messages" + ); + + info( + "Check whether onAvailable will be called with the future platform messages" + ); + Services.console.logStringMessage(expectedMessages[0]); + Services.console.logStringMessage(expectedMessages[1]); + + await waitUntil(() => availableResources.length === expectedMessages.length); + for (let i = 0; i < expectedMessages.length; i++) { + const { message } = availableResources[i]; + const expected = expectedMessages[i]; + is(message, expected, `Message[${i}] is correct`); + } + + Services.console.reset(); + await targetList.destroy(); + await client.close(); +} diff --git a/devtools/shared/resources/tests/browser_resources_root_node.js b/devtools/shared/resources/tests/browser_resources_root_node.js new file mode 100644 index 0000000000..44c9b7347f --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_root_node.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around ROOT_NODE + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +/** + * The original test still asserts some scenarios using several watchRootNode + * call sites, which is not something we intend to support at the moment in the + * resource watcher. + * + * Otherwise this test checks the basic behavior of the resource when reloading + * an empty page. + */ +add_task(async function() { + // Open a test tab + const tab = await addTab("data:text/html,Root Node tests"); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + const browser = gBrowser.selectedBrowser; + + info("Call watchResources([ROOT_NODE], ...)"); + let onAvailableCounter = 0; + const onAvailable = resources => (onAvailableCounter += resources.length); + await resourceWatcher.watchResources([ResourceWatcher.TYPES.ROOT_NODE], { + onAvailable, + }); + + info("Wait until onAvailable has been called"); + await waitUntil(() => onAvailableCounter === 1); + is(onAvailableCounter, 1, "onAvailable has been called 1 time"); + + info("Reload the selected browser"); + browser.reload(); + + info( + "Wait until the watchResources([ROOT_NODE], ...) callback has been called" + ); + await waitUntil(() => onAvailableCounter === 2); + + is(onAvailableCounter, 2, "onAvailable has been called 2 times"); + + info("Call unwatchResources([ROOT_NODE], ...) for the onAvailable callback"); + resourceWatcher.unwatchResources([ResourceWatcher.TYPES.ROOT_NODE], { + onAvailable, + }); + + info("Reload the selected browser"); + const reloaded = BrowserTestUtils.browserLoaded(browser); + browser.reload(); + await reloaded; + + is( + onAvailableCounter, + 2, + "onAvailable was not called after calling unwatchResources" + ); + + // Cleanup + targetList.destroy(); + await client.close(); +}); + +/** + * Test that the watchRootNode API provides the expected node fronts. + */ +add_task(async function testRootNodeFrontIsCorrect() { + const tab = await addTab("data:text/html,<div id=div1>"); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + const browser = gBrowser.selectedBrowser; + + info("Call watchResources([ROOT_NODE], ...)"); + + let rootNodeResolve; + let rootNodePromise = new Promise(r => (rootNodeResolve = r)); + const onAvailable = ([rootNodeFront]) => rootNodeResolve(rootNodeFront); + await resourceWatcher.watchResources([ResourceWatcher.TYPES.ROOT_NODE], { + onAvailable, + }); + + info("Wait until onAvailable has been called"); + const root1 = await rootNodePromise; + ok(!!root1, "onAvailable has been called with a valid argument"); + is( + root1.resourceType, + ResourceWatcher.TYPES.ROOT_NODE, + "The resource has the expected type" + ); + + info("Check we can query an expected node under the retrieved root"); + const div1 = await root1.walkerFront.querySelector(root1, "div"); + is(div1.getAttribute("id"), "div1", "Correct root node retrieved"); + + info("Reload the selected browser"); + rootNodePromise = new Promise(r => (rootNodeResolve = r)); + browser.reload(); + + const root2 = await rootNodePromise; + ok( + root1 !== root2, + "onAvailable has been called with a different node front after reload" + ); + + info("Navigate to another URL"); + rootNodePromise = new Promise(r => (rootNodeResolve = r)); + BrowserTestUtils.loadURI(browser, `data:text/html,<div id=div3>`); + const root3 = await rootNodePromise; + info("Check we can query an expected node under the retrieved root"); + const div3 = await root3.walkerFront.querySelector(root3, "div"); + is(div3.getAttribute("id"), "div3", "Correct root node retrieved"); + + // Cleanup + resourceWatcher.unwatchResources([ResourceWatcher.TYPES.ROOT_NODE], { + onAvailable, + }); + targetList.destroy(); + await client.close(); +}); diff --git a/devtools/shared/resources/tests/browser_resources_several_resources.js b/devtools/shared/resources/tests/browser_resources_several_resources.js new file mode 100644 index 0000000000..b197eddf54 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_several_resources.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +/** + * Check that the resource watcher is still properly watching for new targets + * after unwatching one resource, if there is still another watched resource. + */ +add_task(async function() { + // We will create a main process target list here in order to monitor + // resources from new tabs as they get created. + // devtools.browsertoolbox.fission should be true to monitor resources from + // remote browsers & frames. + await pushPref("devtools.browsertoolbox.fission", true); + + // Open a test tab + const tab = await addTab("data:text/html,Root Node tests"); + + const { + client, + resourceWatcher, + targetList, + } = await initMultiProcessResourceWatcher(); + + const { CONSOLE_MESSAGE, ROOT_NODE } = ResourceWatcher.TYPES; + + // We are only interested in console messages as a resource, the ROOT_NODE one + // is here to test the ResourceWatcher::unwatchResources API with several resources. + const receivedMessages = []; + const onAvailable = resources => { + for (const resource of resources) { + if (resource.resourceType === CONSOLE_MESSAGE) { + receivedMessages.push(resource); + } + } + }; + + info("Call watchResources([CONSOLE_MESSAGE, ROOT_NODE], ...)"); + await resourceWatcher.watchResources([CONSOLE_MESSAGE, ROOT_NODE], { + onAvailable, + }); + + info("Use console.log in the content page"); + logInTab(tab, "test from data-url"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the data-url tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test from data-url" + ) + ); + + // Check that the resource watcher captures resources from new targets. + info("Open a first tab on the example.com domain"); + const comTab = await addTab( + "http://example.com/document-builder.sjs?html=com" + ); + info("Use console.log in the example.com page"); + logInTab(comTab, "test-from-example-com"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.com tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test-from-example-com" + ) + ); + + info("Stop watching ROOT_NODE resources"); + await resourceWatcher.unwatchResources([ROOT_NODE], { onAvailable }); + + // Check that messages from new targets are still captured after calling + // unwatch for another resource. + info("Open a second tab on the example.net domain"); + const netTab = await addTab( + "http://example.net/document-builder.sjs?html=net" + ); + info("Use console.log in the example.net page"); + logInTab(netTab, "test-from-example-net"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.net tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test-from-example-net" + ) + ); + + info("Stop watching CONSOLE_MESSAGE resources"); + await resourceWatcher.unwatchResources([CONSOLE_MESSAGE], { onAvailable }); + await logInTab(tab, "test-again"); + + // We don't have a specific event to wait for here, so allow some time for + // the message to be received. + await wait(1000); + + is( + receivedMessages.find( + resource => resource.message.arguments[0] === "test-again" + ), + undefined, + "The resource watcher should not watch CONSOLE_MESSAGE anymore" + ); + + // Cleanup + targetList.destroy(); + await client.close(); +}); + +function logInTab(tab, message) { + return ContentTask.spawn(tab.linkedBrowser, message, function(_message) { + content.console.log(_message); + }); +} diff --git a/devtools/shared/resources/tests/browser_resources_sources.js b/devtools/shared/resources/tests/browser_resources_sources.js new file mode 100644 index 0000000000..68f533d713 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_sources.js @@ -0,0 +1,198 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around SOURCE. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const TEST_URL = URL_ROOT_SSL + "sources.html"; + +add_task(async function() { + const tab = await addTab(TEST_URL); + + const htmlRequest = await fetch(TEST_URL); + const htmlContent = await htmlRequest.text(); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + // Force the target list to cover workers + targetList.listenForWorkers = true; + targetList.listenForServiceWorkers = true; + await targetList.startListening(); + + const targets = []; + await targetList.watchTargets(targetList.ALL_TYPES, async function({ + targetFront, + }) { + targets.push(targetFront); + }); + is(targets.length, 3, "Got expected number of targets"); + + info("Check already available resources"); + const availableResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.SOURCE], { + onAvailable: resources => availableResources.push(...resources), + }); + + const expectedExistingResources = [ + { + description: "independent js file", + sourceForm: { + introductionType: "scriptElement", + sourceMapBaseURL: + "https://example.com/browser/devtools/shared/resources/tests/sources.js", + url: + "https://example.com/browser/devtools/shared/resources/tests/sources.js", + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction scriptSource() {}\n", + }, + }, + { + description: "eval", + sourceForm: { + introductionType: "eval", + sourceMapBaseURL: + "https://example.com/browser/devtools/shared/resources/tests/sources.html", + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + }, + sourceContent: { + contentType: "text/javascript", + source: "this.global = function evalFunction() {}", + }, + }, + { + description: "inline JS", + sourceForm: { + introductionType: "scriptElement", + sourceMapBaseURL: + "https://example.com/browser/devtools/shared/resources/tests/sources.html", + url: + "https://example.com/browser/devtools/shared/resources/tests/sources.html", + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + }, + sourceContent: { + contentType: "text/html", + source: htmlContent, + }, + }, + { + description: "worker script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: + "https://example.com/browser/devtools/shared/resources/tests/worker-sources.js", + url: + "https://example.com/browser/devtools/shared/resources/tests/worker-sources.js", + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction workerSource() {}\n", + }, + }, + { + description: "service worker script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: + "https://example.com/browser/devtools/shared/resources/tests/service-worker-sources.js", + url: + "https://example.com/browser/devtools/shared/resources/tests/service-worker-sources.js", + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction serviceWorkerSource() {}\n", + }, + }, + ]; + await assertResources(availableResources, expectedExistingResources); + + await targetList.stopListening(); + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +async function assertResources(resources, expected) { + is( + resources.length, + expected.length, + "Length of existing resources is correct at initial" + ); + for (let i = 0; i < resources.length; i++) { + await assertResource(resources[i], expected); + } +} + +async function assertResource(source, expected) { + info(`Checking resource "#${expected.description}"`); + + is( + source.resourceType, + ResourceWatcher.TYPES.SOURCE, + "Resource type is correct" + ); + + const threadFront = await source.targetFront.getFront("thread"); + // `source` is SourceActor's form() + // so try to instantiate the related SourceFront: + const sourceFront = threadFront.source(source); + // then fetch source content + const sourceContent = await sourceFront.source(); + + // Order of sources is random, so we have to find the best expected resource. + // The only unique attribute is the JS Source text content. + const matchingExpected = expected.find(res => { + return res.sourceContent.source == sourceContent.source; + }); + ok( + matchingExpected, + `This source was expected with source content being "${sourceContent.source}"` + ); + assertObject( + sourceContent, + matchingExpected.sourceContent, + matchingExpected.description + ); + + assertObject( + source, + matchingExpected.sourceForm, + matchingExpected.description + ); +} + +function assertObject(object, expected, description) { + for (const field in expected) { + is( + object[field], + expected[field], + `The value of ${field} is correct for "#${description}"` + ); + } +} diff --git a/devtools/shared/resources/tests/browser_resources_stylesheets.js b/devtools/shared/resources/tests/browser_resources_stylesheets.js new file mode 100644 index 0000000000..79735cae0e --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_stylesheets.js @@ -0,0 +1,506 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around STYLESHEET. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const STYLE_TEST_URL = URL_ROOT_SSL + "style_document.html"; + +const EXISTING_RESOURCES = [ + { + styleText: "body { color: lime; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/resources/tests/style_document.html", + isNew: false, + disabled: false, + ruleCount: 1, + mediaRules: [], + }, + { + styleText: "body { margin: 1px; }", + href: + "https://example.com/browser/devtools/shared/resources/tests/style_document.css", + nodeHref: + "https://example.com/browser/devtools/shared/resources/tests/style_document.html", + isNew: false, + disabled: false, + ruleCount: 1, + mediaRules: [], + }, + { + styleText: "body { background-color: pink; }", + href: null, + nodeHref: + "https://example.org/browser/devtools/shared/resources/tests/style_iframe.html", + isNew: false, + disabled: false, + ruleCount: 1, + mediaRules: [], + }, + { + styleText: "body { padding: 1px; }", + href: + "https://example.org/browser/devtools/shared/resources/tests/style_iframe.css", + nodeHref: + "https://example.org/browser/devtools/shared/resources/tests/style_iframe.html", + isNew: false, + disabled: false, + ruleCount: 1, + mediaRules: [], + }, +]; + +const ADDITIONAL_RESOURCE = { + styleText: + "@media all { body { color: red; } } @media print { body { color: cyan; } } body { font-size: 10px; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/resources/tests/style_document.html", + isNew: false, + disabled: false, + ruleCount: 3, + mediaRules: [ + { + conditionText: "all", + mediaText: "all", + matches: true, + line: 1, + column: 1, + }, + { + conditionText: "print", + mediaText: "print", + matches: false, + line: 1, + column: 37, + }, + ], +}; + +const ADDITIONAL_FROM_ACTOR_RESOURCE = { + styleText: "body { font-size: 10px; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/resources/tests/style_document.html", + isNew: true, + disabled: false, + ruleCount: 1, + mediaRules: [], +}; + +add_task(async function() { + await pushPref("devtools.testing.enableServerWatcherSupport", false); + await testResourceAvailableFeature(); + await testResourceUpdateFeature(); + await testNestedResourceUpdateFeature(); + + await pushPref("devtools.testing.enableServerWatcherSupport", true); + await testResourceAvailableFeature(); + await testResourceUpdateFeature(); + await testNestedResourceUpdateFeature(); +}); + +async function testResourceAvailableFeature() { + info("Check resource available feature of the ResourceWatcher"); + + const tab = await addTab(STYLE_TEST_URL); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Check whether ResourceWatcher gets existing stylesheet"); + const availableResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + for (let i = 0; i < availableResources.length; i++) { + const availableResource = availableResources[i]; + // We can not expect the resources to always be forwarded in the same order. + // See intermittent Bug 1655016. + const expectedResource = findMatchingExpectedResource(availableResource); + ok(expectedResource, "Found a matching expected resource for the resource"); + await assertResource(availableResource, expectedResource); + } + + info("Check whether ResourceWatcher gets additonal stylesheet"); + await ContentTask.spawn( + tab.linkedBrowser, + ADDITIONAL_RESOURCE.styleText, + text => { + const document = content.document; + const stylesheet = document.createElement("style"); + stylesheet.textContent = text; + document.body.appendChild(stylesheet); + } + ); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 1 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_RESOURCE + ); + + info( + "Check whether ResourceWatcher gets additonal stylesheet which is added by DevTool" + ); + const styleSheetsFront = await targetList.targetFront.getFront("stylesheets"); + await styleSheetsFront.addStyleSheet( + ADDITIONAL_FROM_ACTOR_RESOURCE.styleText + ); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 2 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_FROM_ACTOR_RESOURCE + ); + + await targetList.destroy(); + await client.close(); +} + +async function testResourceUpdateFeature() { + info("Check resource update feature of the ResourceWatcher"); + + const tab = await addTab(STYLE_TEST_URL); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Setup the watcher"); + const availableResources = []; + const updates = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + onUpdated: newUpdates => updates.push(...newUpdates), + }); + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + + info("Check toggleDisabled function"); + const resource = availableResources[0]; + const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); + await styleSheetsFront.toggleDisabled(resource.resourceId); + await waitUntil(() => updates.length === 1); + + // Check the content of the update object. + assertUpdate(updates[0].update, { + resourceId: resource.resourceId, + updateType: "property-change", + }); + is( + updates[0].update.resourceUpdates.disabled, + true, + "resourceUpdates is correct" + ); + + // Check whether the cached resource is updated correctly. + is( + updates[0].resource.disabled, + true, + "cached resource is updated correctly" + ); + + // Check whether the actual stylesheet is updated correctly. + const styleSheetDisabled = await ContentTask.spawn( + tab.linkedBrowser, + null, + () => { + const document = content.document; + const stylesheet = document.styleSheets[0]; + return stylesheet.disabled; + } + ); + is(styleSheetDisabled, true, "actual stylesheet is is updated correctly"); + + info("Check update function"); + const expectedMediaRules = [ + { + conditionText: "screen", + mediaText: "screen", + matches: true, + }, + { + conditionText: "print", + mediaText: "print", + matches: false, + }, + ]; + + const updateCause = "updated-by-test"; + await styleSheetsFront.update( + resource.resourceId, + "@media screen { color: red; } @media print { color: green; } body { color: cyan; }", + false, + updateCause + ); + await waitUntil(() => updates.length === 4); + + assertUpdate(updates[1].update, { + resourceId: resource.resourceId, + updateType: "property-change", + }); + is( + updates[1].update.resourceUpdates.ruleCount, + 3, + "resourceUpdates is correct" + ); + is(updates[1].resource.ruleCount, 3, "cached resource is updated correctly"); + + assertUpdate(updates[2].update, { + resourceId: resource.resourceId, + updateType: "style-applied", + event: { + cause: updateCause, + }, + }); + is( + updates[2].update.resourceUpdates, + undefined, + "resourceUpdates is correct" + ); + + assertUpdate(updates[3].update, { + resourceId: resource.resourceId, + updateType: "media-rules-changed", + }); + assertMediaRules( + updates[3].update.resourceUpdates.mediaRules, + expectedMediaRules + ); + + // Check the actual page. + const styleSheetResult = await getStyleSheetResult(tab); + + is( + styleSheetResult.ruleCount, + 3, + "ruleCount of actual stylesheet is updated correctly" + ); + assertMediaRules(styleSheetResult.mediaRules, expectedMediaRules); + + await targetList.destroy(); + await client.close(); +} + +async function testNestedResourceUpdateFeature() { + info("Check nested resource update feature of the ResourceWatcher"); + + const tab = await addTab(STYLE_TEST_URL); + + const { + outerWidth: originalWindowWidth, + outerHeight: originalWindowHeight, + } = tab.ownerGlobal; + + registerCleanupFunction(() => { + tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Setup the watcher"); + const availableResources = []; + const updates = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + onUpdated: newUpdates => updates.push(...newUpdates), + }); + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + + info("Apply new media query"); + // In order to avoid applying the media query (min-height: 400px). + tab.ownerGlobal.resizeTo(originalWindowWidth, 300); + + const resource = availableResources[0]; + const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); + await styleSheetsFront.update( + resource.resourceId, + "@media (min-height: 400px) { color: red; }", + false + ); + await waitUntil(() => updates.length === 3); + is(resource.mediaRules[0].matches, false, "Media query is not matched yet"); + + info("Change window size to fire matches-change event"); + tab.ownerGlobal.resizeTo(originalWindowWidth, 500); + await waitUntil(() => updates.length === 4); + + // Check the update content. + const isServerWatcher = Services.prefs.getBoolPref( + "devtools.testing.enableServerWatcherSupport" + ); + const targetUpdate = updates[3]; + assertUpdate(targetUpdate.update, { + resourceId: resource.resourceId, + updateType: "matches-change", + }); + ok(resource === targetUpdate.resource, "Update object has the same resource"); + + if (isServerWatcher) { + is( + JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path), + JSON.stringify(["mediaRules", 0, "matches"]), + "path of nestedResourceUpdates is correct" + ); + is( + targetUpdate.update.nestedResourceUpdates[0].value, + true, + "value of nestedResourceUpdates is correct" + ); + } else { + is( + JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path), + JSON.stringify(["mediaRules", 0]), + "path of nestedResourceUpdates is correct" + ); + is( + targetUpdate.update.nestedResourceUpdates[0].value.matches, + true, + "value of nestedResourceUpdates is correct" + ); + } + + // Check the resource. + const expectedMediaRules = [ + { + conditionText: "(min-height: 400px)", + mediaText: "(min-height: 400px)", + matches: true, + }, + ]; + + assertMediaRules(targetUpdate.resource.mediaRules, expectedMediaRules); + + // Check the actual page. + const styleSheetResult = await getStyleSheetResult(tab); + is( + styleSheetResult.ruleCount, + 1, + "ruleCount of actual stylesheet is updated correctly" + ); + assertMediaRules(styleSheetResult.mediaRules, expectedMediaRules); + + tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight); + + await targetList.destroy(); + await client.close(); +} + +function findMatchingExpectedResource(resource) { + return EXISTING_RESOURCES.find( + expected => + resource.href === expected.href && resource.nodeHref === expected.nodeHref + ); +} + +async function getStyleSheetResult(tab) { + const result = await ContentTask.spawn(tab.linkedBrowser, null, () => { + const document = content.document; + const stylesheet = document.styleSheets[0]; + const ruleCount = stylesheet.cssRules.length; + + const mediaRules = []; + for (const rule of stylesheet.cssRules) { + if (!rule.media) { + continue; + } + + let matches = false; + try { + const mql = content.matchMedia(rule.media.mediaText); + matches = mql.matches; + } catch (e) { + // Ignored + } + + mediaRules.push({ + mediaText: rule.media.mediaText, + conditionText: rule.conditionText, + matches, + }); + } + + return { ruleCount, mediaRules }; + }); + + return result; +} + +function assertMediaRules(mediaRules, expected) { + is(mediaRules.length, expected.length, "Length of the mediaRules is correct"); + + for (let i = 0; i < mediaRules.length; i++) { + is( + mediaRules[i].conditionText, + expected[i].conditionText, + "conditionText is correct" + ); + is(mediaRules[i].mediaText, expected[i].mediaText, "mediaText is correct"); + is(mediaRules[i].matches, expected[i].matches, "matches is correct"); + + if (expected[i].line !== undefined) { + is(mediaRules[i].line, expected[i].line, "line is correct"); + } + + if (expected[i].column !== undefined) { + is(mediaRules[i].column, expected[i].column, "column is correct"); + } + } +} + +async function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceWatcher.TYPES.STYLESHEET, + "Resource type is correct" + ); + const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); + const styleText = ( + await styleSheetsFront.getText(resource.resourceId) + ).str.trim(); + is(styleText, expected.styleText, "Style text is correct"); + is(resource.href, expected.href, "href is correct"); + is(resource.nodeHref, expected.nodeHref, "nodeHref is correct"); + is(resource.isNew, expected.isNew, "isNew is correct"); + is(resource.disabled, expected.disabled, "disabled is correct"); + is(resource.ruleCount, expected.ruleCount, "ruleCount is correct"); + assertMediaRules(resource.mediaRules, expected.mediaRules); +} + +function assertUpdate(update, expected) { + is( + update.resourceType, + ResourceWatcher.TYPES.STYLESHEET, + "Resource type is correct" + ); + is(update.resourceId, expected.resourceId, "resourceId is correct"); + is(update.updateType, expected.updateType, "updateType is correct"); + if (expected.event?.cause) { + is(update.event?.cause, expected.event.cause, "cause is correct"); + } +} diff --git a/devtools/shared/resources/tests/browser_resources_target_destroy.js b/devtools/shared/resources/tests/browser_resources_target_destroy.js new file mode 100644 index 0000000000..9e993ef8cc --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_target_destroy.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the server ResourceWatcher are destroyed when the associated target actors +// are destroyed. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +add_task(async function() { + const tab = await addTab("data:text/html,Test"); + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + // Start watching for console messages. We don't care about messages here, only the + // registration/destroy mechanism, so we make onAvailable a no-op function. + await resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CONSOLE_MESSAGE], + { + onAvailable: () => {}, + } + ); + + info( + "Spawn a content task in order to be able to manipulate actors and resource watchers directly" + ); + await ContentTask.spawn(tab.linkedBrowser, [], function() { + const { require } = ChromeUtils.import( + "resource://devtools/shared/Loader.jsm" + ); + const { + TargetActorRegistry, + } = require("devtools/server/actors/targets/target-actor-registry.jsm"); + const { + getResourceWatcher, + TYPES, + } = require("devtools/server/actors/resources/index"); + + // Retrieve the target actor instance and its watcher for console messages + const targetActor = TargetActorRegistry.getTargetActor( + content.browsingContext.browserId + ); + const watcher = getResourceWatcher(targetActor, TYPES.CONSOLE_MESSAGE); + + // Storing the target actor in the global so we can retrieve it later, even if it + // was destroyed + content._testTargetActor = targetActor; + + is(!!watcher, true, "The console message resource watcher was created"); + }); + + info("Close the client, which will destroy the target"); + targetList.destroy(); + await client.close(); + + info( + "Spawn a content task in order to run some assertions on actors and resource watchers directly" + ); + await ContentTask.spawn(tab.linkedBrowser, [], function() { + const { require } = ChromeUtils.import( + "resource://devtools/shared/Loader.jsm" + ); + const { + getResourceWatcher, + TYPES, + } = require("devtools/server/actors/resources/index"); + + ok( + content._testTargetActor && !content._testTargetActor.actorID, + "The target was destroyed when the client was closed" + ); + + // Retrieve the console message resource watcher + const watcher = getResourceWatcher( + content._testTargetActor, + TYPES.CONSOLE_MESSAGE + ); + + is( + !!watcher, + false, + "The console message resource watcher isn't registered anymore after the target was destroyed" + ); + + // Cleanup work variable + delete content._testTargetActor; + }); +}); diff --git a/devtools/shared/resources/tests/browser_resources_target_resources_race.js b/devtools/shared/resources/tests/browser_resources_target_resources_race.js new file mode 100644 index 0000000000..c7e18f8ce1 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_target_resources_race.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +/** + * Test initial target resources are correctly retrieved even when several calls + * to watchResources are made simultaneously. + * + * This checks a race condition which occurred when calling watchResources + * simultaneously. This made the "second" call to watchResources miss existing + * resources (in case those are emitted from the target instead of the watcher). + * See Bug 1663896. + */ +add_task(async function() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + const { + client, + resourceWatcher, + targetList, + } = await initMultiProcessResourceWatcher(); + + const expectedPlatformMessage = "expectedMessage"; + + info("Log a message *before* calling ResourceWatcher.watchResources"); + Services.console.logStringMessage(expectedPlatformMessage); + + info("Call watchResources from 2 separate call sites consecutively"); + + // Empty onAvailable callback for CSS MESSAGES, we only want to check that + // the second resource we watch correctly provides existing resources. + const onCssMessageAvailable = resources => {}; + + // First call to watchResources. + // We do not await on `watchPromise1` here, in order to simulate simultaneous + // calls to watchResources (which could come from 2 separate modules in a real + // scenario). + const initialWatchPromise = resourceWatcher.watchResources( + [ResourceWatcher.TYPES.CSS_MESSAGE], + { + onAvailable: onCssMessageAvailable, + } + ); + + // `waitForNextResource` will trigger another call to `watchResources`. + const onMessageReceived = waitForNextResource( + resourceWatcher, + ResourceWatcher.TYPES.PLATFORM_MESSAGE, + { + ignoreExistingResources: false, + predicate: r => r.message === expectedPlatformMessage, + } + ); + + info("Waiting for the expected message to be received"); + await onMessageReceived; + ok(true, "All the expected messages were received"); + + info("Wait for the other watchResources promise to finish"); + await initialWatchPromise; + + // Unwatch all resources. + resourceWatcher.unwatchResources([ResourceWatcher.TYPES.CSS_MESSAGE], { + onAvailable: onCssMessageAvailable, + }); + + Services.console.reset(); + targetList.destroy(); + await client.close(); +}); diff --git a/devtools/shared/resources/tests/browser_resources_target_switching.js b/devtools/shared/resources/tests/browser_resources_target_switching.js new file mode 100644 index 0000000000..1388970dcd --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_target_switching.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the behavior of ResourceWatcher when the top level target changes + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); +const { CONSOLE_MESSAGE, SOURCE } = ResourceWatcher.TYPES; + +const TEST_URI = + "data:text/html;charset=utf-8,<script>console.log('foo');</script>"; + +add_task(async function() { + const tab = await addTab(TEST_URI); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Check the resources gotten from getAllResources at initial"); + is( + resourceWatcher.getAllResources(CONSOLE_MESSAGE).length, + 0, + "There is no resources before calling watchResources" + ); + + info( + "Start to watch the available resources in order to compare with resources gotten from getAllResources" + ); + const availableResources = []; + const onAvailable = resources => { + // Ignore message coming from shared worker started by previous tests and + // logging late a console message + resources + .filter(r => { + return !r.message.arguments[0].startsWith("[WORKER] started"); + }) + .map(r => availableResources.push(r)); + }; + await resourceWatcher.watchResources([CONSOLE_MESSAGE], { onAvailable }); + + is(availableResources.length, 1, "Got the page message"); + is( + availableResources[0].message.arguments[0], + "foo", + "Got the expected page message" + ); + + // Register another listener before unregistering the console listener + // otherwise the resource watcher stop watching for targets + const onSourceAvailable = () => {}; + await resourceWatcher.watchResources([SOURCE], { + onAvailable: onSourceAvailable, + }); + + info( + "Unregister the console listener and check that we no longer listen for console messages" + ); + resourceWatcher.unwatchResources([CONSOLE_MESSAGE], { + onAvailable, + }); + + let onSwitched = targetList.once("switched-target"); + info("Navigate to another process"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, "about:robots"); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await onSwitched; + + is( + availableResources.length, + 1, + "about:robots doesn't fire any new message, so we should have a new one" + ); + + info("Navigate back to data: URI"); + onSwitched = targetList.once("switched-target"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, TEST_URI); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await onSwitched; + + is( + availableResources.length, + 1, + "the data:URI fired a message, but we are no longer listening to it, so no new one should be notified" + ); + is( + resourceWatcher.getAllResources(CONSOLE_MESSAGE).length, + 0, + "As we are no longer listening to CONSOLE message, we should not collect any" + ); + + resourceWatcher.unwatchResources([SOURCE], { + onAvailable: onSourceAvailable, + }); + + await targetList.destroy(); + await client.close(); +}); diff --git a/devtools/shared/resources/tests/browser_resources_websocket.js b/devtools/shared/resources/tests/browser_resources_websocket.js new file mode 100644 index 0000000000..2437836d79 --- /dev/null +++ b/devtools/shared/resources/tests/browser_resources_websocket.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceWatcher API around WEBSOCKET. + +const { + ResourceWatcher, +} = require("devtools/shared/resources/resource-watcher"); + +const TEST_URL = URL_ROOT + "websocket_frontend.html"; +const IS_NUMBER = "IS_NUMBER"; + +add_task(async function() { + const tab = await addTab(TEST_URL); + + const { client, resourceWatcher, targetList } = await initResourceWatcher( + tab + ); + + info("Check available resources at initial"); + const availableResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.WEBSOCKET], { + onAvailable: resources => availableResources.push(...resources), + }); + is( + availableResources.length, + 0, + "Length of existing resources is correct at initial" + ); + + info("Check resource of opening websocket"); + await ContentTask.spawn(tab.linkedBrowser, [], async () => { + await content.wrappedJSObject.openConnection(); + }); + await waitUntil(() => availableResources.length === 1); + const httpChannelId = availableResources[0].httpChannelId; + ok(httpChannelId, "httpChannelId is present in the resource"); + assertResource(availableResources[0], { + wsMessageType: "webSocketOpened", + effectiveURI: + "ws://mochi.test:8888/browser/devtools/shared/resources/tests/websocket_backend", + extensions: "permessage-deflate", + protocols: "", + }); + + info("Check resource of sending/receiving the data via websocket"); + await ContentTask.spawn(tab.linkedBrowser, [], async () => { + await content.wrappedJSObject.sendData("test"); + }); + await waitUntil(() => availableResources.length === 3); + assertResource(availableResources[1], { + wsMessageType: "frameSent", + httpChannelId, + data: { + type: "sent", + payload: "test", + }, + }); + assertResource(availableResources[2], { + wsMessageType: "frameReceived", + httpChannelId, + data: { + type: "received", + payload: "test", + }, + }); + + info("Check resource of closing websocket"); + await ContentTask.spawn(tab.linkedBrowser, [], async () => { + await content.wrappedJSObject.closeConnection(); + }); + await waitUntil(() => availableResources.length === 6); + assertResource(availableResources[3], { + wsMessageType: "frameSent", + httpChannelId, + data: { + type: "sent", + payload: "", + }, + }); + assertResource(availableResources[4], { + wsMessageType: "frameReceived", + httpChannelId, + data: { + type: "received", + payload: "", + }, + }); + assertResource(availableResources[5], { + wsMessageType: "webSocketClosed", + httpChannelId, + code: IS_NUMBER, + reason: "", + wasClean: true, + }); + + info("Check existing resources"); + const existingResources = []; + await resourceWatcher.watchResources([ResourceWatcher.TYPES.WEBSOCKET], { + onAvailable: resources => existingResources.push(...resources), + }); + is( + availableResources.length, + existingResources.length, + "Length of existing resources is correct" + ); + for (let i = 0; i < availableResources.length; i++) { + const availableResource = availableResources[i]; + const existingResource = existingResources[i]; + ok( + availableResource === existingResource, + `The ${i}th resource is correct` + ); + } + + await targetList.destroy(); + await client.close(); +}); + +function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceWatcher.TYPES.WEBSOCKET, + "Resource type is correct" + ); + + assertObject(resource, expected); +} + +function assertObject(object, expected) { + for (const field in expected) { + if (typeof expected[field] === "object") { + assertObject(object[field], expected[field]); + } else if (expected[field] === IS_NUMBER) { + ok(!isNaN(object[field]), `The value of ${field} is number`); + } else { + is(object[field], expected[field], `The value of ${field} is correct`); + } + } +} diff --git a/devtools/shared/resources/tests/browser_target_list_browser_workers.js b/devtools/shared/resources/tests/browser_target_list_browser_workers.js new file mode 100644 index 0000000000..fa85708165 --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_browser_workers.js @@ -0,0 +1,195 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API around workers + +const { TargetList } = require("devtools/shared/resources/target-list"); +const { TYPES } = TargetList; + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const WORKER_FILE = "test_worker.js"; +const CHROME_WORKER_URL = CHROME_URL_ROOT + WORKER_FILE; +const SERVICE_WORKER_URL = URL_ROOT_SSL + "test_service_worker.js"; + +add_task(async function() { + // Enabled fission's pref as the TargetList is almost disabled without it + await pushPref("devtools.browsertoolbox.fission", true); + + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + const tab = await addTab(FISSION_TEST_URL); + + info("Test TargetList against workers via the parent process target"); + + // Instantiate a worker in the parent process + // eslint-disable-next-line no-unused-vars + const worker = new Worker(CHROME_WORKER_URL + "#simple-worker"); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker(CHROME_WORKER_URL + "#shared-worker"); + + const targetDescriptor = await mainRoot.getMainProcess(); + const target = await targetDescriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + await targetList.startListening(); + + // Very naive sanity check against getAllTargets([workerType]) + info("Check that getAllTargets returned the expected targets"); + const workers = await targetList.getAllTargets([TYPES.WORKER]); + const hasWorker = workers.find(workerTarget => { + return workerTarget.url == CHROME_WORKER_URL + "#simple-worker"; + }); + ok(hasWorker, "retrieve the target for the worker"); + + const sharedWorkers = await targetList.getAllTargets([TYPES.SHARED_WORKER]); + const hasSharedWorker = sharedWorkers.find(workerTarget => { + return workerTarget.url == CHROME_WORKER_URL + "#shared-worker"; + }); + ok(hasSharedWorker, "retrieve the target for the shared worker"); + + const serviceWorkers = await targetList.getAllTargets([TYPES.SERVICE_WORKER]); + const hasServiceWorker = serviceWorkers.find(workerTarget => { + return workerTarget.url == SERVICE_WORKER_URL; + }); + ok(hasServiceWorker, "retrieve the target for the service worker"); + + info( + "Check that calling getAllTargets again return the same target instances" + ); + const workers2 = await targetList.getAllTargets([TYPES.WORKER]); + const sharedWorkers2 = await targetList.getAllTargets([TYPES.SHARED_WORKER]); + const serviceWorkers2 = await targetList.getAllTargets([ + TYPES.SERVICE_WORKER, + ]); + is(workers2.length, workers.length, "retrieved the same number of workers"); + is( + sharedWorkers2.length, + sharedWorkers.length, + "retrieved the same number of shared workers" + ); + is( + serviceWorkers2.length, + serviceWorkers.length, + "retrieved the same number of service workers" + ); + + workers.sort(sortFronts); + workers2.sort(sortFronts); + sharedWorkers.sort(sortFronts); + sharedWorkers2.sort(sortFronts); + serviceWorkers.sort(sortFronts); + serviceWorkers2.sort(sortFronts); + + for (let i = 0; i < workers.length; i++) { + is(workers[i], workers2[i], `worker ${i} targets are the same`); + } + for (let i = 0; i < sharedWorkers2.length; i++) { + is( + sharedWorkers[i], + sharedWorkers2[i], + `shared worker ${i} targets are the same` + ); + } + for (let i = 0; i < serviceWorkers2.length; i++) { + is( + serviceWorkers[i], + serviceWorkers2[i], + `service worker ${i} targets are the same` + ); + } + + info( + "Check that watchTargets will call the create callback for all existing workers" + ); + const targets = []; + const onAvailable = async ({ targetFront }) => { + ok( + targetFront.targetType === TYPES.WORKER || + targetFront.targetType === TYPES.SHARED_WORKER || + targetFront.targetType === TYPES.SERVICE_WORKER, + "We are only notified about worker targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.push(targetFront); + }; + await targetList.watchTargets( + [TYPES.WORKER, TYPES.SHARED_WORKER, TYPES.SERVICE_WORKER], + onAvailable + ); + is( + targets.length, + workers.length + sharedWorkers.length + serviceWorkers.length, + "retrieved the same number of workers via watchTargets" + ); + + targets.sort(sortFronts); + const allWorkers = workers + .concat(sharedWorkers, serviceWorkers) + .sort(sortFronts); + + for (let i = 0; i < allWorkers.length; i++) { + is( + allWorkers[i], + targets[i], + `worker ${i} targets are the same via watchTargets` + ); + } + + targetList.unwatchTargets( + [TYPES.WORKER, TYPES.SHARED_WORKER, TYPES.SERVICE_WORKER], + onAvailable + ); + + // Create a new worker and see if the worker target is reported + const onWorkerCreated = new Promise(resolve => { + const onAvailable2 = async ({ targetFront }) => { + if (targets.includes(targetFront)) { + return; + } + targetList.unwatchTargets([TYPES.WORKER], onAvailable2); + resolve(targetFront); + }; + targetList.watchTargets([TYPES.WORKER], onAvailable2); + }); + // eslint-disable-next-line no-unused-vars + const worker2 = new Worker(CHROME_WORKER_URL + "#second"); + info("Wait for the second worker to be created"); + const workerTarget = await onWorkerCreated; + + is( + workerTarget.url, + CHROME_WORKER_URL + "#second", + "This worker target is about the new worker" + ); + + const workers3 = await targetList.getAllTargets([TYPES.WORKER]); + const hasWorker2 = workers3.find( + ({ url }) => url == `${CHROME_WORKER_URL}#second` + ); + ok(hasWorker2, "retrieve the target for tab via getAllTargets"); + + targetList.destroy(); + + info("Unregister service workers so they don't appear in other tests."); + await unregisterAllServiceWorkers(client); + + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +function sortFronts(f1, f2) { + return f1.actorID < f2.actorID; +} diff --git a/devtools/shared/resources/tests/browser_target_list_frames.js b/devtools/shared/resources/tests/browser_target_list_frames.js new file mode 100644 index 0000000000..0549c67842 --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_frames.js @@ -0,0 +1,168 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API around frames + +const { TargetList } = require("devtools/shared/resources/target-list"); + +const FISSION_TEST_URL = URL_ROOT_SSL + "/fission_document.html"; + +add_task(async function() { + // Enabled fission prefs + await pushPref("devtools.browsertoolbox.fission", true); + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + + // Test fetching the frames from the main process target + await testBrowserFrames(mainRoot); + + // Test fetching the frames from a tab target + await testTabFrames(mainRoot); + + await client.close(); +}); + +async function testBrowserFrames(mainRoot) { + info("Test TargetList against frames via the parent process target"); + + const targetDescriptor = await mainRoot.getMainProcess(); + const target = await targetDescriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + await targetList.startListening(); + + // Very naive sanity check against getAllTargets([frame]) + const frames = await targetList.getAllTargets([TargetList.TYPES.FRAME]); + const hasBrowserDocument = frames.find( + frameTarget => frameTarget.url == window.location.href + ); + ok(hasBrowserDocument, "retrieve the target for the browser document"); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames2 = await targetList.getAllTargets([TargetList.TYPES.FRAME]); + is(frames2.length, frames.length, "retrieved the same number of frames"); + + function sortFronts(f1, f2) { + return f1.actorID < f2.actorID; + } + frames.sort(sortFronts); + frames2.sort(sortFronts); + for (let i = 0; i < frames.length; i++) { + is(frames[i], frames2[i], `frame ${i} targets are the same`); + } + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const onAvailable = ({ targetFront }) => { + is( + targetFront.targetType, + TargetList.TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.push(targetFront); + }; + await targetList.watchTargets([TargetList.TYPES.FRAME], onAvailable); + is( + targets.length, + frames.length, + "retrieved the same number of frames via watchTargets" + ); + + frames.sort(sortFronts); + targets.sort(sortFronts); + for (let i = 0; i < frames.length; i++) { + is( + frames[i], + targets[i], + `frame ${i} targets are the same via watchTargets` + ); + } + targetList.unwatchTargets([TargetList.TYPES.FRAME], onAvailable); + + /* NOT READY YET, need to implement frame listening + // Open a new tab and see if the frame target is reported by watchTargets and getAllTargets + const tab = await addTab(TEST_URL); + + is(targets.length, frames.length + 1, "Opening a tab reported a new frame"); + is(targets[targets.length - 1].url, TEST_URL, "This frame target is about the new tab"); + + const frames3 = await targetList.getAllTargets([TargetList.TYPES.FRAME]); + const hasTabDocument = frames3.find(target => target.url == TEST_URL); + ok(hasTabDocument, "retrieve the target for tab via getAllTargets"); + */ + + targetList.destroy(); + await waitForAllTargetsToBeAttached(targetList); +} + +async function testTabFrames(mainRoot) { + info("Test TargetList against frames via a tab target"); + + // Create a TargetList for a given test tab + const tab = await addTab(FISSION_TEST_URL); + const descriptor = await mainRoot.getTab({ tab }); + const target = await descriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + + await targetList.startListening(); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetList.getAllTargets([TargetList.TYPES.FRAME]); + // When fission is enabled, we also get the remote example.org iframe. + const expectedFramesCount = isFissionEnabled() ? 2 : 1; + is( + frames.length, + expectedFramesCount, + "retrieved only the top level document" + ); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const onAvailable = ({ targetFront }) => { + is( + targetFront.targetType, + TargetList.TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.push(targetFront); + }; + await targetList.watchTargets([TargetList.TYPES.FRAME], onAvailable); + is( + targets.length, + frames.length, + "retrieved the same number of frames via watchTargets" + ); + + for (const frame of frames) { + ok( + targets.find(t => t === frame), + "frame " + frame.actorID + " target is the same via watchTargets" + ); + } + targetList.unwatchTargets([TargetList.TYPES.FRAME], onAvailable); + + targetList.destroy(); + await waitForAllTargetsToBeAttached(targetList); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); + BrowserTestUtils.removeTab(tab); +} diff --git a/devtools/shared/resources/tests/browser_target_list_getAllTargets.js b/devtools/shared/resources/tests/browser_target_list_getAllTargets.js new file mode 100644 index 0000000000..85822a7ede --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_getAllTargets.js @@ -0,0 +1,108 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API getAllTargets. + +const { TargetList } = require("devtools/shared/resources/target-list"); +const { ALL_TYPES, TYPES } = TargetList; + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const CHROME_WORKER_URL = CHROME_URL_ROOT + "test_worker.js"; + +add_task(async function() { + // Enabled devtools.browsertoolbox.fission to listen to all target types. + await pushPref("devtools.browsertoolbox.fission", true); + + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + info("Setup the test page with workers of all types"); + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + + const tab = await addTab(FISSION_TEST_URL); + + // Instantiate a worker in the parent process + // eslint-disable-next-line no-unused-vars + const worker = new Worker(CHROME_WORKER_URL + "#simple-worker"); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker(CHROME_WORKER_URL + "#shared-worker"); + + info("Create a target list for the main process target"); + const targetDescriptor = await mainRoot.getMainProcess(); + const target = await targetDescriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + await targetList.startListening(); + + info("Check getAllTargets will throw when providing invalid arguments"); + Assert.throws( + () => targetList.getAllTargets(), + e => e.message === "getAllTargets expects a non-empty array of types" + ); + + Assert.throws( + () => targetList.getAllTargets([]), + e => e.message === "getAllTargets expects a non-empty array of types" + ); + + info("Check getAllTargets returns consistent results with several types"); + const workerTargets = targetList.getAllTargets([TYPES.WORKER]); + const serviceWorkerTargets = targetList.getAllTargets([TYPES.SERVICE_WORKER]); + const sharedWorkerTargets = targetList.getAllTargets([TYPES.SHARED_WORKER]); + const processTargets = targetList.getAllTargets([TYPES.PROCESS]); + const frameTargets = targetList.getAllTargets([TYPES.FRAME]); + + const allWorkerTargetsReference = [ + ...workerTargets, + ...serviceWorkerTargets, + ...sharedWorkerTargets, + ]; + const allWorkerTargets = targetList.getAllTargets([ + TYPES.WORKER, + TYPES.SERVICE_WORKER, + TYPES.SHARED_WORKER, + ]); + + is( + allWorkerTargets.length, + allWorkerTargetsReference.length, + "getAllTargets([worker, service, shared]) returned the expected number of targets" + ); + + ok( + allWorkerTargets.every(t => allWorkerTargetsReference.includes(t)), + "getAllTargets([worker, service, shared]) returned the expected targets" + ); + + const allTargetsReference = [ + ...allWorkerTargets, + ...processTargets, + ...frameTargets, + ]; + const allTargets = targetList.getAllTargets(ALL_TYPES); + is( + allTargets.length, + allTargetsReference.length, + "getAllTargets(TYPES.ALL_TYPES) returned the expected number of targets" + ); + + ok( + allTargets.every(t => allTargetsReference.includes(t)), + "getAllTargets(TYPES.ALL_TYPES) returned the expected targets" + ); + + targetList.destroy(); + + // Wait for all the targets to be fully attached so we don't have pending requests. + await waitForAllTargetsToBeAttached(targetList); + + await client.close(); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); diff --git a/devtools/shared/resources/tests/browser_target_list_preffedoff.js b/devtools/shared/resources/tests/browser_target_list_preffedoff.js new file mode 100644 index 0000000000..244ffe6c53 --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_preffedoff.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API when DevTools Fission preference is false + +const { TargetList } = require("devtools/shared/resources/target-list"); + +add_task(async function() { + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + const targetDescriptor = await mainRoot.getMainProcess(); + const mainProcess = await targetDescriptor.getTarget(); + + // Assert the limited behavior of this API with fission preffed off + await pushPref("devtools.browsertoolbox.fission", false); + + // Test with Main process targets as top level target + await testPreffedOffMainProcess(mainRoot, mainProcess); + + await client.close(); +}); + +async function testPreffedOffMainProcess(mainRoot, mainProcess) { + info( + "Test TargetList when devtools's fission pref is false, via the parent process target" + ); + + const targetList = new TargetList(mainRoot, mainProcess); + await targetList.startListening(); + + // The API should only report the top level target, + // i.e. the Main process target, which is considered as frame + // and not as process. + const processes = await targetList.getAllTargets([TargetList.TYPES.PROCESS]); + is( + processes.length, + 0, + "We only get a frame target for the top level target" + ); + const frames = await targetList.getAllTargets([TargetList.TYPES.FRAME]); + is(frames.length, 1, "We get only one frame when preffed-off"); + is( + frames[0], + mainProcess, + "The target is the top level one via getAllTargets" + ); + + const processTargets = []; + const onProcessAvailable = ({ targetFront }) => { + processTargets.push(targetFront); + }; + await targetList.watchTargets([TargetList.TYPES.PROCESS], onProcessAvailable); + is(processTargets.length, 0, "We get no process when preffed-off"); + targetList.unwatchTargets([TargetList.TYPES.PROCESS], onProcessAvailable); + + const frameTargets = []; + const onFrameAvailable = ({ targetFront }) => { + is( + targetFront.targetType, + TargetList.TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront.isTopLevel, + "We are only notified about the top level target" + ); + frameTargets.push(targetFront); + }; + await targetList.watchTargets([TargetList.TYPES.FRAME], onFrameAvailable); + is( + frameTargets.length, + 1, + "We get one frame via watchTargets when preffed-off" + ); + is( + frameTargets[0], + mainProcess, + "The target is the top level one via watchTargets" + ); + targetList.unwatchTargets([TargetList.TYPES.FRAME], onFrameAvailable); + + targetList.destroy(); +} diff --git a/devtools/shared/resources/tests/browser_target_list_processes.js b/devtools/shared/resources/tests/browser_target_list_processes.js new file mode 100644 index 0000000000..f3614b399d --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_processes.js @@ -0,0 +1,195 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API around processes + +const { TargetList } = require("devtools/shared/resources/target-list"); + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function() { + // Enabled fission's pref as the TargetList is almost disabled without it + await pushPref("devtools.browsertoolbox.fission", true); + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + const targetDescriptor = await mainRoot.getMainProcess(); + const mainProcess = await targetDescriptor.getTarget(); + + const targetList = new TargetList(mainRoot, mainProcess); + await targetList.startListening(); + + await testProcesses(targetList, mainProcess); + + await targetList.destroy(); + // Wait for all the targets to be fully attached so we don't have pending requests. + await Promise.all( + targetList + .getAllTargets(targetList.ALL_TYPES) + .map(t => t.attachAndInitThread(targetList)) + ); + + await client.close(); +}); + +async function testProcesses(targetList, target) { + info("Test TargetList against processes"); + + // Note that ppmm also includes the parent process, which is considered as a frame rather than a process + const originalProcessesCount = Services.ppmm.childCount - 1; + const processes = await targetList.getAllTargets([TargetList.TYPES.PROCESS]); + is( + processes.length, + originalProcessesCount, + "Get a target for all content processes" + ); + + const processes2 = await targetList.getAllTargets([TargetList.TYPES.PROCESS]); + is( + processes2.length, + originalProcessesCount, + "retrieved the same number of processes" + ); + function sortFronts(f1, f2) { + return f1.actorID < f2.actorID; + } + processes.sort(sortFronts); + processes2.sort(sortFronts); + for (let i = 0; i < processes.length; i++) { + is(processes[i], processes2[i], `process ${i} targets are the same`); + } + + // Assert that watchTargets will call the create callback for all existing frames + const targets = new Set(); + const onAvailable = ({ targetFront }) => { + if (targets.has(targetFront)) { + ok(false, "The same target is notified multiple times via onAvailable"); + } + is( + targetFront.targetType, + TargetList.TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.add(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + if (!targets.has(targetFront)) { + ok( + false, + "A target is declared destroyed via onDestroyed without being notified via onAvailable" + ); + } + is( + targetFront.targetType, + TargetList.TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + !targetFront.isTopLevel, + "We are never notified about the top level target destruction" + ); + targets.delete(targetFront); + }; + await targetList.watchTargets( + [TargetList.TYPES.PROCESS], + onAvailable, + onDestroyed + ); + is( + targets.size, + originalProcessesCount, + "retrieved the same number of processes via watchTargets" + ); + for (let i = 0; i < processes.length; i++) { + ok( + targets.has(processes[i]), + `process ${i} targets are the same via watchTargets` + ); + } + + const previousTargets = new Set(targets); + // Assert that onAvailable is called for processes created *after* the call to watchTargets + const onProcessCreated = new Promise(resolve => { + const onAvailable2 = ({ targetFront }) => { + if (previousTargets.has(targetFront)) { + return; + } + targetList.unwatchTargets([TargetList.TYPES.PROCESS], onAvailable2); + resolve(targetFront); + }; + targetList.watchTargets([TargetList.TYPES.PROCESS], onAvailable2); + }); + const tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + const createdTarget = await onProcessCreated; + // For some reason, creating a new tab purges processes created from previous tests + // so it is not reasonable to assert the size of `targets` as it may be lower than expected. + ok(targets.has(createdTarget), "The new tab process is in the list"); + + const processCountAfterTabOpen = targets.size; + + // Assert that onDestroyed is called for destroyed processes + const onProcessDestroyed = new Promise(resolve => { + const onAvailable3 = () => {}; + const onDestroyed3 = ({ targetFront }) => { + resolve(targetFront); + targetList.unwatchTargets( + [TargetList.TYPES.PROCESS], + onAvailable3, + onDestroyed3 + ); + }; + targetList.watchTargets( + [TargetList.TYPES.PROCESS], + onAvailable3, + onDestroyed3 + ); + }); + + BrowserTestUtils.removeTab(tab1); + + const destroyedTarget = await onProcessDestroyed; + is( + targets.size, + processCountAfterTabOpen - 1, + "The closed tab's process has been reported as destroyed" + ); + ok( + !targets.has(destroyedTarget), + "The destroyed target is no longer in the list" + ); + is( + destroyedTarget, + createdTarget, + "The destroyed target is the one that has been reported as created" + ); + + targetList.unwatchTargets( + [TargetList.TYPES.PROCESS], + onAvailable, + onDestroyed + ); + + // Ensure that getAllTargets still works after the call to unwatchTargets + const processes3 = await targetList.getAllTargets([TargetList.TYPES.PROCESS]); + is( + processes3.length, + processCountAfterTabOpen - 1, + "getAllTargets reports a new target" + ); +} diff --git a/devtools/shared/resources/tests/browser_target_list_service_workers.js b/devtools/shared/resources/tests/browser_target_list_service_workers.js new file mode 100644 index 0000000000..7af04144b1 --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_service_workers.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API for service workers in content tabs. + +const { TargetList } = require("devtools/shared/resources/target-list"); +const { TYPES } = TargetList; + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; + +add_task(async function() { + // Enabled devtools.browsertoolbox.fission to listen to all target types. + await pushPref("devtools.browsertoolbox.fission", true); + + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + info("Setup the test page with workers of all types"); + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + + const tab = await addTab(FISSION_TEST_URL); + + info("Create a target list for a tab target"); + const descriptor = await mainRoot.getTab({ tab }); + const target = await descriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + + // Enable Service Worker listening. + targetList.listenForServiceWorkers = true; + await targetList.startListening(); + + const serviceWorkerTargets = targetList.getAllTargets([TYPES.SERVICE_WORKER]); + is(serviceWorkerTargets.length, 1, "TargetList has 1 service worker target"); + + info("Check that the onAvailable is done when watchTargets resolves"); + const targets = []; + const onAvailable = async ({ targetFront }) => { + // Wait for one second here to check that watch targets waits for + // the onAvailable callbacks correctly. + await wait(1000); + targets.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => + targets.splice(targets.indexOf(targetFront), 1); + + await targetList.watchTargets( + [TYPES.SERVICE_WORKER], + onAvailable, + onDestroyed + ); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + is(targets.length, 1, "onAvailable has resolved"); + is( + targets[0], + serviceWorkerTargets[0], + "onAvailable was called with the expected service worker target" + ); + + info("Unregister the worker and wait until onDestroyed is called."); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); + await waitUntil(() => targets.length === 0); + + // Stop listening to avoid worker related requests + targetList.destroy(); + + await client.waitForRequestsToSettle(); + + await client.close(); +}); diff --git a/devtools/shared/resources/tests/browser_target_list_service_workers_navigation.js b/devtools/shared/resources/tests/browser_target_list_service_workers_navigation.js new file mode 100644 index 0000000000..dc9afdfe4c --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_service_workers_navigation.js @@ -0,0 +1,393 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API for service workers when navigating in content tabs. +// When the top level target navigates, we manually call onTargetAvailable for +// service workers which now match the page domain. We assert that the callbacks +// will be called the expected number of times here. + +const { TargetList } = require("devtools/shared/resources/target-list"); +const { TYPES } = TargetList; + +const COM_PAGE_URL = URL_ROOT_SSL + "test_sw_page.html"; +const COM_WORKER_URL = URL_ROOT_SSL + "test_sw_page_worker.js"; +const ORG_PAGE_URL = URL_ROOT_ORG_SSL + "test_sw_page.html"; +const ORG_WORKER_URL = URL_ROOT_ORG_SSL + "test_sw_page_worker.js"; + +/** + * This test will navigate between two pages, both controlled by different + * service workers. + * + * The steps will be: + * - navigate to .com page + * - create target list + * - navigate to .org page + * - reload .org page + * - unregister .org worker + * - navigate back to .com page + * - unregister .com worker + * + * First we test this with destroyServiceWorkersOnNavigation = false. + * In this case we expect the following calls: + * - navigate to .com page + * - create target list + * - onAvailable should be called for the .com worker + * - navigate to .org page + * - onAvailable should be called for the .org worker + * - reload .org page + * - nothing should happen + * - unregister .org worker + * - onDestroyed should be called for the .org worker + * - navigate back to .com page + * - nothing should happen + * - unregister .com worker + * - onDestroyed should be called for the .com worker + */ +add_task(async function test_NavigationBetweenTwoDomains_NoDestroy() { + const { client, mainRoot } = await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + const { hooks, targetList } = await watchServiceWorkerTargets({ + mainRoot, + tab, + destroyServiceWorkersOnNavigation: false, + }); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [COM_WORKER_URL], + }); + + info("Go to .org page, wait for onAvailable to be called"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, ORG_PAGE_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 0, + targets: [COM_WORKER_URL, ORG_WORKER_URL], + }); + + info("Reload .org page, onAvailable and onDestroyed should not be called"); + const reloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + gBrowser.reloadTab(gBrowser.selectedTab); + await reloaded; + await checkHooks(hooks, { + available: 2, + destroyed: 0, + targets: [COM_WORKER_URL, ORG_WORKER_URL], + }); + + info("Unregister .org service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, ORG_PAGE_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [COM_WORKER_URL], + }); + + info("Go back to page 1"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, COM_PAGE_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [COM_WORKER_URL], + }); + + info("Unregister .com service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, COM_PAGE_URL); + await checkHooks(hooks, { available: 2, destroyed: 2, targets: [] }); + + // Stop listening to avoid worker related requests + targetList.destroy(); + + await client.waitForRequestsToSettle(); + await client.close(); + await removeTab(tab); +}); + +/** + * Same scenario as test_NavigationBetweenTwoDomains_NoDestroy, but this time + * with destroyServiceWorkersOnNavigation set to true. + * + * In this case we expect the following calls: + * - navigate to .com page + * - create target list + * - onAvailable should be called for the .com worker + * - navigate to .org page + * - onDestroyed should be called for the .com worker + * - onAvailable should be called for the .org worker + * - reload .org page + * - onDestroyed & onAvailable should be called for the .org worker + * - unregister .org worker + * - onDestroyed should be called for the .org worker + * - navigate back to .com page + * - onAvailable should be called for the .com worker + * - unregister .com worker + * - onDestroyed should be called for the .com worker + */ +add_task(async function test_NavigationBetweenTwoDomains_WithDestroy() { + const { client, mainRoot } = await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + const { hooks, targetList } = await watchServiceWorkerTargets({ + mainRoot, + tab, + destroyServiceWorkersOnNavigation: true, + }); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [COM_WORKER_URL], + }); + + info("Go to .org page, wait for onAvailable to be called"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, ORG_PAGE_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [ORG_WORKER_URL], + }); + + info("Reload .org page, onAvailable and onDestroyed should be called"); + gBrowser.reloadTab(gBrowser.selectedTab); + await checkHooks(hooks, { + available: 3, + destroyed: 2, + targets: [ORG_WORKER_URL], + }); + + info("Unregister .org service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, ORG_PAGE_URL); + await checkHooks(hooks, { available: 3, destroyed: 3, targets: [] }); + + info("Go back to page 1, wait for onDestroyed and onAvailable to be called"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, COM_PAGE_URL); + await checkHooks(hooks, { + available: 4, + destroyed: 3, + targets: [COM_WORKER_URL], + }); + + info("Unregister .com service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, COM_PAGE_URL); + await checkHooks(hooks, { available: 4, destroyed: 4, targets: [] }); + + // Stop listening to avoid worker related requests + targetList.destroy(); + + await client.waitForRequestsToSettle(); + await client.close(); + await removeTab(tab); +}); + +/** + * In this test we load a service worker in a page prior to starting the + * TargetList. We start the target list on another page, and then we go back to + * the first page. We want to check that we are correctly notified about the + * worker that was spawned before TargetList. + * + * Steps: + * - navigate to .com page + * - navigate to .org page + * - create target list + * - unregister .org worker + * - navigate back to .com page + * - unregister .com worker + * + * The expected calls are the same whether destroyServiceWorkersOnNavigation is + * true or false. + * + * Expected calls: + * - navigate to .com page + * - navigate to .org page + * - create target list + * - onAvailable is called for the .org worker + * - unregister .org worker + * - onDestroyed is called for the .org worker + * - navigate back to .com page + * - onAvailable is called for the .com worker + * - unregister .com worker + * - onDestroyed is called for the .com worker + */ +add_task(async function test_NavigationToPageWithExistingWorker_NoDestroy() { + await testNavigationToPageWithExistingWorker({ + destroyServiceWorkersOnNavigation: false, + }); +}); + +add_task(async function test_NavigationToPageWithExistingWorker_WithDestroy() { + await testNavigationToPageWithExistingWorker({ + destroyServiceWorkersOnNavigation: true, + }); +}); + +async function testNavigationToPageWithExistingWorker({ + destroyServiceWorkersOnNavigation, +}) { + const { client, mainRoot } = await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + info("Wait until the service worker registration is registered"); + await waitForRegistrationReady(tab, COM_PAGE_URL); + + info("Navigate to another page"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, ORG_PAGE_URL); + + // Avoid TV failures, where target list still starts thinking that the + // current domain is .com . + info("Wait until we have fully navigated to the .org page"); + await waitForRegistrationReady(tab, ORG_PAGE_URL); + + const { hooks, targetList } = await watchServiceWorkerTargets({ + mainRoot, + tab, + destroyServiceWorkersOnNavigation, + }); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [ORG_WORKER_URL], + }); + + info("Unregister .org service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, ORG_PAGE_URL); + await checkHooks(hooks, { available: 1, destroyed: 1, targets: [] }); + + info("Go back .com page, wait for onAvailable to be called"); + BrowserTestUtils.loadURI(gBrowser.selectedBrowser, COM_PAGE_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [COM_WORKER_URL], + }); + + info("Unregister .com service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, COM_PAGE_URL); + await checkHooks(hooks, { available: 2, destroyed: 2, targets: [] }); + + // Stop listening to avoid worker related requests + targetList.destroy(); + + await client.waitForRequestsToSettle(); + await client.close(); + await removeTab(tab); +} + +async function setupServiceWorkerNavigationTest() { + // Enabled devtools.browsertoolbox.fission to listen to all target types. + await pushPref("devtools.browsertoolbox.fission", true); + + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + info("Setup the test page with workers of all types"); + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + return { client, mainRoot }; +} + +async function watchServiceWorkerTargets({ + destroyServiceWorkersOnNavigation, + mainRoot, + tab, +}) { + info("Create a target list for a tab target"); + const descriptor = await mainRoot.getTab({ tab }); + const target = await descriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + + // Enable Service Worker listening. + targetList.listenForServiceWorkers = true; + info( + "Set targetList.destroyServiceWorkersOnNavigation to " + + destroyServiceWorkersOnNavigation + ); + targetList.destroyServiceWorkersOnNavigation = destroyServiceWorkersOnNavigation; + await targetList.startListening(); + + // Setup onAvailable & onDestroyed callbacks so that we can check how many + // times they are called and with which targetFront. + const hooks = { + availableCount: 0, + destroyedCount: 0, + targets: [], + }; + + const onAvailable = async ({ targetFront }) => { + hooks.availableCount++; + hooks.targets.push(targetFront); + }; + + const onDestroyed = ({ targetFront }) => { + hooks.destroyedCount++; + hooks.targets.splice(hooks.targets.indexOf(targetFront), 1); + }; + + await targetList.watchTargets( + [TYPES.SERVICE_WORKER], + onAvailable, + onDestroyed + ); + + return { hooks, targetList }; +} + +async function unregisterServiceWorker(tab, expectedPageUrl) { + await waitForRegistrationReady(tab, expectedPageUrl); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +} + +/** + * Wait until the expected URL is loaded and win.registration has resolved. + */ +async function waitForRegistrationReady(tab, expectedPageUrl) { + await asyncWaitUntil(() => + SpecialPowers.spawn(tab.linkedBrowser, [expectedPageUrl], function(_url) { + try { + const win = content.wrappedJSObject; + const isExpectedUrl = win.location.href === _url; + const hasRegistration = !!win.registrationPromise; + return isExpectedUrl && hasRegistration; + } catch (e) { + return false; + } + }) + ); +} + +/** + * Assert helper for the `hooks` object, updated by the onAvailable and + * onDestroyed callbacks. Assert that the callbacks have been called the + * expected number of times, with the expected targets. + */ +async function checkHooks(hooks, { available, destroyed, targets }) { + info(`Wait for availableCount=${available} and destroyedCount=${destroyed}`); + await waitUntil( + () => hooks.availableCount == available && hooks.destroyedCount == destroyed + ); + is(hooks.availableCount, available, "onAvailable was called as expected"); + is(hooks.destroyedCount, destroyed, "onDestroyed was called as expected"); + + is(hooks.targets.length, targets.length, "Expected number of targets"); + targets.forEach((url, i) => { + is(hooks.targets[i].url, url, `SW target ${i} has the expected url`); + }); +} diff --git a/devtools/shared/resources/tests/browser_target_list_switchToTarget.js b/devtools/shared/resources/tests/browser_target_list_switchToTarget.js new file mode 100644 index 0000000000..ea166e40fc --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_switchToTarget.js @@ -0,0 +1,147 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API switchToTarget function + +const { TargetList } = require("devtools/shared/resources/target-list"); + +add_task(async function() { + const client = await createLocalClient(); + + await testSwitchToTarget(client); + + await client.close(); +}); + +async function testSwitchToTarget(client) { + info("Test TargetList.switchToTarget method"); + + const { mainRoot } = client; + // Create a first target to switch from, a new tab with an iframe + const firstTab = await addTab( + `data:text/html,<iframe src="data:text/html,foo"></iframe>` + ); + const firstDescriptor = await mainRoot.getTab({ tab: gBrowser.selectedTab }); + const firstTarget = await firstDescriptor.getTarget(); + + const targetList = new TargetList(mainRoot, firstTarget); + + await targetList.startListening(); + + is( + targetList.targetFront, + firstTarget, + "The target list top level target is the main process one" + ); + + // Create a second target to switch to, a new tab with an iframe + const secondTab = await addTab( + `data:text/html,<iframe src="data:text/html,bar"></iframe>` + ); + const secondDescriptor = await mainRoot.getTab({ tab: gBrowser.selectedTab }); + const secondTarget = await secondDescriptor.getTarget(); + + const frameTargets = []; + let currentTarget = firstTarget; + const onFrameAvailable = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TargetList.TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == currentTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + if (targetFront.isTopLevel) { + // When calling watchTargets, this will be false, but it will be true when calling switchToTarget + is( + isTargetSwitching, + currentTarget == secondTarget, + "target switching boolean is correct" + ); + } else { + ok(!isTargetSwitching, "for now, only top level target can be switched"); + } + frameTargets.push(targetFront); + }; + const destroyedTargets = []; + const onFrameDestroyed = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TargetList.TYPES.FRAME, + "target-destroyed: We are only notified about frame targets" + ); + ok( + targetFront == firstTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "target-destroyed: isTopLevel property is correct" + ); + if (targetFront.isTopLevel) { + is( + isTargetSwitching, + true, + "target-destroyed: target switching boolean is correct" + ); + } else { + ok( + !isTargetSwitching, + "target-destroyed: for now, only top level target can be switched" + ); + } + destroyedTargets.push(targetFront); + }; + await targetList.watchTargets( + [TargetList.TYPES.FRAME], + onFrameAvailable, + onFrameDestroyed + ); + + // Save the original list of targets + const createdTargets = [...frameTargets]; + // Clear the recorded target list of all existing targets + frameTargets.length = 0; + + currentTarget = secondTarget; + await targetList.switchToTarget(secondTarget); + + is( + targetList.targetFront, + currentTarget, + "After the switch, the top level target has been updated" + ); + // Because JS Window Actor API isn't used yet, FrameDescriptor.getTarget returns null + // And there is no target being created for the iframe, yet. + // As soon as bug 1565200 is resolved, this should return two frames, including the iframe. + is( + frameTargets.length, + 1, + "We get the report of the top level iframe when switching to the new target" + ); + is(frameTargets[0], currentTarget); + //is(frameTargets[1].url, "data:text/html,foo"); + + // Ensure that all the targets reported before the call to switchToTarget + // are reported as destroyed while calling switchToTarget. + is( + destroyedTargets.length, + createdTargets.length, + "All targets original reported are destroyed" + ); + for (const newTarget of createdTargets) { + ok( + destroyedTargets.includes(newTarget), + "Each originally target is reported as destroyed" + ); + } + + targetList.destroy(); + + BrowserTestUtils.removeTab(firstTab); + BrowserTestUtils.removeTab(secondTab); +} diff --git a/devtools/shared/resources/tests/browser_target_list_tab_workers.js b/devtools/shared/resources/tests/browser_target_list_tab_workers.js new file mode 100644 index 0000000000..e4e1a5a0dd --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_tab_workers.js @@ -0,0 +1,330 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList API around workers + +const { TargetList } = require("devtools/shared/resources/target-list"); +const { TYPES } = TargetList; + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const IFRAME_FILE = "fission_iframe.html"; +const REMOTE_IFRAME_URL = URL_ROOT_ORG_SSL + IFRAME_FILE; +const IFRAME_URL = URL_ROOT_SSL + IFRAME_FILE; +const WORKER_FILE = "test_worker.js"; +const WORKER_URL = URL_ROOT_SSL + WORKER_FILE; +const IFRAME_WORKER_URL = WORKER_FILE; + +add_task(async function() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + + // The WorkerDebuggerManager#getWorkerDebuggerEnumerator method we're using to retrieve + // workers loops through _all_ the workers in the process, which means it goes over workers + // from other tabs as well. Here we add a few tabs that are not going to be used in the + // test, just to check that their workers won't be retrieved by getAllTargets/watchTargets. + await addTab(`${FISSION_TEST_URL}?id=first-untargetted-tab&noServiceWorker`); + await addTab(`${FISSION_TEST_URL}?id=second-untargetted-tab&noServiceWorker`); + + info("Test TargetList against workers via a tab target"); + const tab = await addTab(`${FISSION_TEST_URL}?&noServiceWorker`); + + // Create a TargetList for the tab + const descriptor = await mainRoot.getTab({ tab }); + const target = await descriptor.getTarget(); + + // Ensure attaching the target as BrowsingContextTargetActor.listWorkers + // assert that the target actor is attached. + // It isn't clear if this assertion is meaningful? + await target.attach(); + + const targetList = new TargetList(mainRoot, target); + + // Workaround to allow listening for workers in the content toolbox + // without the fission preferences + targetList.listenForWorkers = true; + + await targetList.startListening(); + + info("Check that getAllTargets only returns dedicated workers"); + const workers = await targetList.getAllTargets([ + TYPES.WORKER, + TYPES.SHARED_WORKER, + ]); + + // XXX: This should be modified in Bug 1607778, where we plan to add support for shared workers. + is(workers.length, 2, "Retrieved two worker…"); + const mainPageWorker = workers.find( + worker => worker.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorker = workers.find( + worker => worker.url == `${IFRAME_WORKER_URL}#simple-worker-in-iframe` + ); + ok(mainPageWorker, "…the dedicated worker on the main page"); + ok(iframeWorker, "…and the dedicated worker on the iframe"); + + info( + "Assert that watchTargets will call the create callback for existing dedicated workers" + ); + const targets = []; + const destroyedTargets = []; + const onAvailable = async ({ targetFront }) => { + info(`onAvailable called for ${targetFront.url}`); + is( + targetFront.targetType, + TYPES.WORKER, + "We are only notified about worker targets" + ); + ok(!targetFront.isTopLevel, "The workers are never top level"); + targets.push(targetFront); + info(`Handled ${targets.length} targets\n`); + }; + const onDestroy = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.WORKER, + "We are only notified about worker targets" + ); + ok(!targetFront.isTopLevel, "The workers are never top level"); + destroyedTargets.push(targetFront); + }; + + await targetList.watchTargets( + [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroy + ); + + // XXX: This should be modified in Bug 1607778, where we plan to add support for shared workers. + info("Check that watched targets return the same fronts as getAllTargets"); + is(targets.length, 2, "watcheTargets retrieved 2 worker…"); + const mainPageWorkerTarget = targets.find(t => t === mainPageWorker); + const iframeWorkerTarget = targets.find(t => t === iframeWorker); + + ok( + mainPageWorkerTarget, + "…the dedicated worker in main page, which is the same front we received from getAllTargets" + ); + ok( + iframeWorkerTarget, + "…the dedicated worker in iframe, which is the same front we received from getAllTargets" + ); + + info("Spawn workers in main page and iframe"); + await SpecialPowers.spawn(tab.linkedBrowser, [WORKER_FILE], workerUrl => { + // Put the worker on the global so we can access it later + content.spawnedWorker = new content.Worker(`${workerUrl}#spawned-worker`); + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [workerUrl], innerWorkerUrl => { + // Put the worker on the global so we can access it later + content.spawnedWorker = new content.Worker( + `${innerWorkerUrl}#spawned-worker-in-iframe` + ); + }); + }); + + await TestUtils.waitForCondition( + () => targets.length === 4, + "Wait for the target list to notify us about the spawned worker" + ); + const mainPageSpawnedWorkerTarget = targets.find( + innerTarget => innerTarget.url === `${WORKER_FILE}#spawned-worker` + ); + ok(mainPageSpawnedWorkerTarget, "Retrieved spawned worker"); + const iframeSpawnedWorkerTarget = targets.find( + innerTarget => innerTarget.url === `${WORKER_FILE}#spawned-worker-in-iframe` + ); + ok(iframeSpawnedWorkerTarget, "Retrieved spawned worker in iframe"); + + await wait(100); + + info( + "Check that the target list calls onDestroy when a worker is terminated" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.spawnedWorker.terminate(); + content.spawnedWorker = null; + + SpecialPowers.spawn(content.document.querySelector("iframe"), [], () => { + content.spawnedWorker.terminate(); + content.spawnedWorker = null; + }); + }); + await TestUtils.waitForCondition( + () => + destroyedTargets.includes(mainPageSpawnedWorkerTarget) && + destroyedTargets.includes(iframeSpawnedWorkerTarget), + "Wait for the target list to notify us about the terminated workers" + ); + + ok( + true, + "The target list handled the terminated workers (from the main page and the iframe)" + ); + + info( + "Check that reloading the page will notify about the terminated worker and the new existing one" + ); + const targetsCountBeforeReload = targets.length; + tab.linkedBrowser.reload(); + + await TestUtils.waitForCondition(() => { + return ( + destroyedTargets.includes(mainPageWorkerTarget) && + destroyedTargets.includes(iframeWorkerTarget) + ); + }, `Wait for the target list to notify us about the terminated workers when reloading`); + ok( + true, + "The target list notified us about all the expected workers being destroyed when reloading" + ); + + await TestUtils.waitForCondition( + () => targets.length === targetsCountBeforeReload + 2, + "Wait for the target list to notify us about the new workers after reloading" + ); + + const mainPageWorkerTargetAfterReload = targets.find( + t => t !== mainPageWorkerTarget && t.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorkerTargetAfterReload = targets.find( + t => + t !== iframeWorkerTarget && + t.url == `${IFRAME_WORKER_URL}#simple-worker-in-iframe` + ); + + ok( + mainPageWorkerTargetAfterReload, + "The target list handled the worker created once the page navigated" + ); + ok( + iframeWorkerTargetAfterReload, + "The target list handled the worker created in the iframe once the page navigated" + ); + + const targetCount = targets.length; + + info( + "Check that when removing an iframe we're notified about its workers being terminated" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.document.querySelector("iframe").remove(); + }); + await TestUtils.waitForCondition(() => { + return destroyedTargets.includes(iframeWorkerTargetAfterReload); + }, `Wait for the target list to notify us about the terminated workers when removing an iframe`); + + info("Check that target list handles adding iframes with workers"); + const iframeUrl = `${IFRAME_URL}?noServiceWorker=true&hashSuffix=in-created-iframe`; + const remoteIframeUrl = `${REMOTE_IFRAME_URL}?noServiceWorker=true&hashSuffix=in-created-remote-iframe`; + + await SpecialPowers.spawn( + tab.linkedBrowser, + [iframeUrl, remoteIframeUrl], + (url, remoteUrl) => { + const firstIframe = content.document.createElement("iframe"); + content.document.body.append(firstIframe); + firstIframe.src = url + "-1"; + + const secondIframe = content.document.createElement("iframe"); + content.document.body.append(secondIframe); + secondIframe.src = url + "-2"; + + const firstRemoteIframe = content.document.createElement("iframe"); + content.document.body.append(firstRemoteIframe); + firstRemoteIframe.src = remoteUrl + "-1"; + + const secondRemoteIframe = content.document.createElement("iframe"); + content.document.body.append(secondRemoteIframe); + secondRemoteIframe.src = remoteUrl + "-2"; + } + ); + + // It's important to check the length of `targets` here to ensure we don't get unwanted + // worker targets. + await TestUtils.waitForCondition( + () => targets.length === targetCount + 4, + "Wait for the target list to notify us about the workers in the new iframes" + ); + const firstSpawnedIframeWorkerTarget = targets.find( + worker => worker.url == `${WORKER_FILE}#simple-worker-in-created-iframe-1` + ); + const secondSpawnedIframeWorkerTarget = targets.find( + worker => worker.url == `${WORKER_FILE}#simple-worker-in-created-iframe-2` + ); + const firstSpawnedRemoteIframeWorkerTarget = targets.find( + worker => + worker.url == `${WORKER_FILE}#simple-worker-in-created-remote-iframe-1` + ); + const secondSpawnedRemoteIframeWorkerTarget = targets.find( + worker => + worker.url == `${WORKER_FILE}#simple-worker-in-created-remote-iframe-2` + ); + + ok( + firstSpawnedIframeWorkerTarget, + "The target list handled the worker in the first new same-origin iframe" + ); + ok( + secondSpawnedIframeWorkerTarget, + "The target list handled the worker in the second new same-origin iframe" + ); + ok( + firstSpawnedRemoteIframeWorkerTarget, + "The target list handled the worker in the first new remote iframe" + ); + ok( + secondSpawnedRemoteIframeWorkerTarget, + "The target list handled the worker in the second new remote iframe" + ); + + info("Check that navigating away does destroy all targets"); + BrowserTestUtils.loadURI( + tab.linkedBrowser, + "data:text/html,<meta charset=utf8>Away" + ); + + await TestUtils.waitForCondition( + () => destroyedTargets.length === targets.length, + "Wait for all the targets to be reporeted as destroyed" + ); + + ok( + destroyedTargets.includes(mainPageWorkerTargetAfterReload), + "main page worker target was destroyed" + ); + ok( + destroyedTargets.includes(firstSpawnedIframeWorkerTarget), + "first spawned same-origin iframe worker target was destroyed" + ); + ok( + destroyedTargets.includes(secondSpawnedIframeWorkerTarget), + "second spawned same-origin iframe worker target was destroyed" + ); + ok( + destroyedTargets.includes(firstSpawnedRemoteIframeWorkerTarget), + "first spawned remote iframe worker target was destroyed" + ); + ok( + destroyedTargets.includes(secondSpawnedRemoteIframeWorkerTarget), + "second spawned remote iframe worker target was destroyed" + ); + + targetList.unwatchTargets( + [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroy + ); + targetList.destroy(); + + info("Unregister service workers so they don't appear in other tests."); + await unregisterAllServiceWorkers(client); + + BrowserTestUtils.removeTab(tab); + await client.close(); +}); diff --git a/devtools/shared/resources/tests/browser_target_list_watchTargets.js b/devtools/shared/resources/tests/browser_target_list_watchTargets.js new file mode 100644 index 0000000000..68d0a381fd --- /dev/null +++ b/devtools/shared/resources/tests/browser_target_list_watchTargets.js @@ -0,0 +1,256 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetList's `watchTargets` function + +const { TargetList } = require("devtools/shared/resources/target-list"); + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function() { + // Enabled fission's pref as the TargetList is almost disabled without it + await pushPref("devtools.browsertoolbox.fission", true); + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + const client = await createLocalClient(); + const mainRoot = client.mainRoot; + + await testWatchTargets(mainRoot); + await testContentProcessTarget(mainRoot); + await testThrowingInOnAvailable(mainRoot); + + await client.close(); +}); + +async function testWatchTargets(mainRoot) { + info("Test TargetList watchTargets function"); + + const targetDescriptor = await mainRoot.getMainProcess(); + const target = await targetDescriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + + await targetList.startListening(); + + // Note that ppmm also includes the parent process, which is considered as a frame rather than a process + const originalProcessesCount = Services.ppmm.childCount - 1; + + info( + "Check that onAvailable is called for processes already created *before* the call to watchTargets" + ); + const targets = new Set(); + const onAvailable = ({ targetFront }) => { + if (targets.has(targetFront)) { + ok(false, "The same target is notified multiple times via onAvailable"); + } + is( + targetFront.targetType, + TargetList.TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.add(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + if (!targets.has(targetFront)) { + ok( + false, + "A target is declared destroyed via onDestroyed without being notified via onAvailable" + ); + } + is( + targetFront.targetType, + TargetList.TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + !targetFront.isTopLevel, + "We are not notified about the top level target destruction" + ); + targets.delete(targetFront); + }; + await targetList.watchTargets( + [TargetList.TYPES.PROCESS], + onAvailable, + onDestroyed + ); + is( + targets.size, + originalProcessesCount, + "retrieved the expected number of processes via watchTargets" + ); + // Start from 1 in order to ignore the parent process target, which is considered as a frame rather than a process + for (let i = 1; i < Services.ppmm.childCount; i++) { + const process = Services.ppmm.getChildAt(i); + const hasTargetWithSamePID = [...targets].find( + processTarget => processTarget.targetForm.processID == process.osPid + ); + ok( + hasTargetWithSamePID, + `Process with PID ${process.osPid} has been reported via onAvailable` + ); + } + + info( + "Check that onAvailable is called for processes created *after* the call to watchTargets" + ); + const previousTargets = new Set(targets); + const onProcessCreated = new Promise(resolve => { + const onAvailable2 = ({ targetFront }) => { + if (previousTargets.has(targetFront)) { + return; + } + targetList.unwatchTargets([TargetList.TYPES.PROCESS], onAvailable2); + resolve(targetFront); + }; + targetList.watchTargets([TargetList.TYPES.PROCESS], onAvailable2); + }); + const tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + const createdTarget = await onProcessCreated; + + // For some reason, creating a new tab purges processes created from previous tests + // so it is not reasonable to assert the side of `targets` as it may be lower than expected. + ok(targets.has(createdTarget), "The new tab process is in the list"); + + const processCountAfterTabOpen = targets.size; + + // Assert that onDestroyed is called for destroyed processes + const onProcessDestroyed = new Promise(resolve => { + const onAvailable3 = () => {}; + const onDestroyed3 = ({ targetFront }) => { + resolve(targetFront); + targetList.unwatchTargets( + [TargetList.TYPES.PROCESS], + onAvailable3, + onDestroyed3 + ); + }; + targetList.watchTargets( + [TargetList.TYPES.PROCESS], + onAvailable3, + onDestroyed3 + ); + }); + + BrowserTestUtils.removeTab(tab1); + + const destroyedTarget = await onProcessDestroyed; + is( + targets.size, + processCountAfterTabOpen - 1, + "The closed tab's process has been reported as destroyed" + ); + ok( + !targets.has(destroyedTarget), + "The destroyed target is no longer in the list" + ); + is( + destroyedTarget, + createdTarget, + "The destroyed target is the one that has been reported as created" + ); + + targetList.unwatchTargets( + [TargetList.TYPES.PROCESS], + onAvailable, + onDestroyed + ); + + targetList.destroy(); +} + +async function testContentProcessTarget(mainRoot) { + info("Test TargetList watchTargets with a content process target"); + + const processes = await mainRoot.listProcesses(); + const target = await processes[1].getTarget(); + const targetList = new TargetList(mainRoot, target); + + await targetList.startListening(); + + // Assert that watchTargets is only called for the top level content process target + // as listening for additional target is only enable for the parent process target. + // See bug 1593928. + const targets = new Set(); + const onAvailable = ({ targetFront }) => { + if (targets.has(targetFront)) { + // This may fail if the top level target is reported by LegacyImplementation + // to TargetList and emits an available event for it. + ok(false, "The same target is notified multiple times via onAvailable"); + } + is( + targetFront.targetType, + TargetList.TYPES.PROCESS, + "We are only notified about process targets" + ); + is(targetFront, target, "This is the existing top level target"); + ok( + targetFront.isTopLevel, + "We are only notified about the top level target" + ); + targets.add(targetFront); + }; + const onDestroyed = _ => { + ok(false, "onDestroyed should never be called in this test"); + }; + await targetList.watchTargets( + [TargetList.TYPES.PROCESS], + onAvailable, + onDestroyed + ); + + // This may fail if the top level target is reported by LegacyImplementation + // to TargetList and registers a duplicated entry + is(targets.size, 1, "We were only notified about the top level target"); + + targetList.destroy(); +} + +async function testThrowingInOnAvailable(mainRoot) { + info( + "Test TargetList watchTargets function when an exception is thrown in onAvailable callback" + ); + + const targetDescriptor = await mainRoot.getMainProcess(); + const target = await targetDescriptor.getTarget(); + const targetList = new TargetList(mainRoot, target); + + await targetList.startListening(); + + // Note that ppmm also includes the parent process, which is considered as a frame rather than a process + const originalProcessesCount = Services.ppmm.childCount - 1; + + info( + "Check that onAvailable is called for processes already created *before* the call to watchTargets" + ); + const targets = new Set(); + let thrown = false; + const onAvailable = ({ targetFront }) => { + if (!thrown) { + thrown = true; + throw new Error("Force an exception when processing the first target"); + } + targets.add(targetFront); + }; + await targetList.watchTargets([TargetList.TYPES.PROCESS], onAvailable); + is( + targets.size, + originalProcessesCount - 1, + "retrieved the expected number of processes via onAvailable. All but the first one where we have thrown." + ); + + targetList.destroy(); +} diff --git a/devtools/shared/resources/tests/early_console_document.html b/devtools/shared/resources/tests/early_console_document.html new file mode 100644 index 0000000000..e4523dbdeb --- /dev/null +++ b/devtools/shared/resources/tests/early_console_document.html @@ -0,0 +1,14 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + console.log("early-page-log"); + </script> +</head> +<body></body> +</html> diff --git a/devtools/shared/resources/tests/fission_document.html b/devtools/shared/resources/tests/fission_document.html new file mode 100644 index 0000000000..24f9704a76 --- /dev/null +++ b/devtools/shared/resources/tests/fission_document.html @@ -0,0 +1,47 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + + const params = new URLSearchParams(document.location.search); + + // eslint-disable-next-line no-unused-vars + const worker = new Worker("https://example.com/browser/devtools/shared/resources/tests/test_worker.js#simple-worker"); + + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker("https://example.com/browser/devtools/shared/resources/tests/test_worker.js#shared-worker"); + + if (!params.has("noServiceWorker")) { + // Expose a reference to the registration so that tests can unregister it. + window.registrationPromise = navigator.serviceWorker.register("https://example.com/browser/devtools/shared/resources/tests/test_service_worker.js#service-worker"); + } + + /* exported logMessageInWorker */ + function logMessageInWorker(message) { + worker.postMessage({ + type: "log-in-worker", + message, + }); + } + </script> +</head> +<body> +<p>Test fission iframe</p> + +<script> + "use strict"; + const iframe = document.createElement("iframe"); + let iframeUrl = `https://example.org/browser/devtools/shared/resources/tests/fission_iframe.html`; + if (document.location.search) { + iframeUrl += `?${new URLSearchParams(document.location.search)}`; + } + iframe.src = iframeUrl; + document.body.append(iframe); +</script> +</body> +</html> diff --git a/devtools/shared/resources/tests/fission_iframe.html b/devtools/shared/resources/tests/fission_iframe.html new file mode 100644 index 0000000000..deae49f833 --- /dev/null +++ b/devtools/shared/resources/tests/fission_iframe.html @@ -0,0 +1,29 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test fission iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <script> + "use strict"; + const params = new URLSearchParams(document.location.search); + const hashSuffix = params.get("hashSuffix") || "in-iframe"; + // eslint-disable-next-line no-unused-vars + const worker = new Worker("test_worker.js#simple-worker-" + hashSuffix); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker("test_worker.js#shared-worker-" + hashSuffix); + + /* exported logMessageInWorker */ + function logMessageInWorker(message) { + worker.postMessage({ + type: "log-in-worker", + message, + }); + } + </script> +</head> +<body> +<p>remote iframe</p> +</body> +</html> diff --git a/devtools/shared/resources/tests/head.js b/devtools/shared/resources/tests/head.js new file mode 100644 index 0000000000..988592f9a1 --- /dev/null +++ b/devtools/shared/resources/tests/head.js @@ -0,0 +1,150 @@ +/* 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"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ +/* import-globals-from ../../../client/shared/test/shared-head.js */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const { DevToolsClient } = require("devtools/client/devtools-client"); +const { DevToolsServer } = require("devtools/server/devtools-server"); + +async function createLocalClient() { + // Instantiate a minimal server + DevToolsServer.init(); + DevToolsServer.allowChromeProcess = true; + if (!DevToolsServer.createRootActor) { + DevToolsServer.registerAllActors(); + } + const transport = DevToolsServer.connectPipe(); + const client = new DevToolsClient(transport); + await client.connect(); + return client; +} + +async function _initResourceWatcherFromDescriptor( + client, + descriptor, + { listenForWorkers = false } = {} +) { + const { TargetList } = require("devtools/shared/resources/target-list"); + const { + ResourceWatcher, + } = require("devtools/shared/resources/resource-watcher"); + + const target = await descriptor.getTarget(); + const targetList = new TargetList(client.mainRoot, target); + if (listenForWorkers) { + targetList.listenForWorkers = true; + } + await targetList.startListening(); + + // Now create a ResourceWatcher + const resourceWatcher = new ResourceWatcher(targetList); + + return { client, resourceWatcher, targetList }; +} + +/** + * Instantiate a ResourceWatcher for the given tab. + * + * @param {Tab} tab + * The browser frontend's tab to connect to. + * @param {Object} options + * @param {Boolean} options.listenForWorkers + * @return {Object} object + * @return {ResourceWatcher} object.client + * The underlying client instance. + * @return {DevToolsClient} object.client + * The underlying client instance. + * @return {DevToolsClient} object.targetList + * The underlying target list instance. + */ +async function initResourceWatcher(tab, options) { + const client = await createLocalClient(); + const descriptor = await client.mainRoot.getTab({ tab }); + return _initResourceWatcherFromDescriptor(client, descriptor, options); +} + +/** + * Instantiate a multi-process ResourceWatcher, watching all type of targets. + * + * @return {Object} object + * @return {ResourceWatcher} object.client + * The underlying client instance. + * @return {DevToolsClient} object.client + * The underlying client instance. + * @return {DevToolsClient} object.targetList + * The underlying target list instance. + */ +async function initMultiProcessResourceWatcher() { + const client = await createLocalClient(); + const descriptor = await client.mainRoot.getMainProcess(); + return _initResourceWatcherFromDescriptor(client, descriptor); +} + +// Copied from devtools/shared/webconsole/test/chrome/common.js +function checkObject(object, expected) { + if (object && object.getGrip) { + object = object.getGrip(); + } + + for (const name of Object.keys(expected)) { + const expectedValue = expected[name]; + const value = object[name]; + checkValue(name, value, expectedValue); + } +} + +function checkValue(name, value, expected) { + if (expected === null) { + is(value, null, `'${name}' is null`); + } else if (value === undefined) { + is(value, undefined, `'${name}' is undefined`); + } else if (value === null) { + is(value, expected, `'${name}' has expected value`); + } else if ( + typeof expected == "string" || + typeof expected == "number" || + typeof expected == "boolean" + ) { + is(value, expected, "property '" + name + "'"); + } else if (expected instanceof RegExp) { + ok(expected.test(value), name + ": " + expected + " matched " + value); + } else if (Array.isArray(expected)) { + info("checking array for property '" + name + "'"); + ok(Array.isArray(value), `property '${name}' is an array`); + + is(value.length, expected.length, "Array has expected length"); + if (value.length !== expected.length) { + is(JSON.stringify(value, null, 2), JSON.stringify(expected, null, 2)); + } else { + checkObject(value, expected); + } + } else if (typeof expected == "object") { + info("checking object for property '" + name + "'"); + checkObject(value, expected); + } +} + +async function triggerNetworkRequests(browser, commands) { + for (let i = 0; i < commands.length; i++) { + await SpecialPowers.spawn(browser, [commands[i]], async function(code) { + const script = content.document.createElement("script"); + script.append( + content.document.createTextNode( + `async function triggerRequest() {${code}}` + ) + ); + content.document.body.append(script); + await content.wrappedJSObject.triggerRequest(); + script.remove(); + }); + } +} diff --git a/devtools/shared/resources/tests/network_document.html b/devtools/shared/resources/tests/network_document.html new file mode 100644 index 0000000000..5c4744cb0c --- /dev/null +++ b/devtools/shared/resources/tests/network_document.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> + <meta http-equiv="Pragma" content="no-cache" /> + <meta http-equiv="Expires" content="0" /> + <title>Test for network events</title> + </head> + <body> + <p>Test for network events</p> + </body> +</html> diff --git a/devtools/shared/resources/tests/service-worker-sources.js b/devtools/shared/resources/tests/service-worker-sources.js new file mode 100644 index 0000000000..614644ee5d --- /dev/null +++ b/devtools/shared/resources/tests/service-worker-sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function serviceWorkerSource() {} diff --git a/devtools/shared/resources/tests/sources.html b/devtools/shared/resources/tests/sources.html new file mode 100644 index 0000000000..d765f25ef0 --- /dev/null +++ b/devtools/shared/resources/tests/sources.html @@ -0,0 +1,22 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + </head> + <body> + <script type="text/javascript"> + "use strict"; + /* eslint-disable */ + function inlineSource() {} + // Assign it to a global in order to avoid it being GCed + eval("this.global = function evalFunction() {}"); + // Assign the worker to a global variable in order to avoid + // having it be GCed. + this.worker = new Worker("worker-sources.js"); + window.registrationPromise = navigator.serviceWorker.register("service-worker-sources.js"); + </script> + <script src="sources.js"></script> + </body> +</html> diff --git a/devtools/shared/resources/tests/sources.js b/devtools/shared/resources/tests/sources.js new file mode 100644 index 0000000000..7ae6c6272b --- /dev/null +++ b/devtools/shared/resources/tests/sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function scriptSource() {} diff --git a/devtools/shared/resources/tests/style_document.css b/devtools/shared/resources/tests/style_document.css new file mode 100644 index 0000000000..aa54533924 --- /dev/null +++ b/devtools/shared/resources/tests/style_document.css @@ -0,0 +1 @@ +body { margin: 1px; } diff --git a/devtools/shared/resources/tests/style_document.html b/devtools/shared/resources/tests/style_document.html new file mode 100644 index 0000000000..1b5e97dfe7 --- /dev/null +++ b/devtools/shared/resources/tests/style_document.html @@ -0,0 +1,16 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf8"> + <title>Test style document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <style> + body { color: lime; } + </style> + <link href="style_document.css" rel="stylesheet" type="text/css"/> + </head> + <body> + <iframe src="https://example.org/browser/devtools/shared/resources/tests/style_iframe.html"></iframe> + </body> +</html> diff --git a/devtools/shared/resources/tests/style_iframe.css b/devtools/shared/resources/tests/style_iframe.css new file mode 100644 index 0000000000..30e7ae802b --- /dev/null +++ b/devtools/shared/resources/tests/style_iframe.css @@ -0,0 +1 @@ +body { padding: 1px; } diff --git a/devtools/shared/resources/tests/style_iframe.html b/devtools/shared/resources/tests/style_iframe.html new file mode 100644 index 0000000000..11ad9f785b --- /dev/null +++ b/devtools/shared/resources/tests/style_iframe.html @@ -0,0 +1,15 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf8"> + <title>Test style iframe document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + <style> + body { background-color: pink; } + </style> + <link href="style_iframe.css" rel="stylesheet" type="text/css"/> + </head> + <body> + </body> +</html> diff --git a/devtools/shared/resources/tests/test_service_worker.js b/devtools/shared/resources/tests/test_service_worker.js new file mode 100644 index 0000000000..3b69a40dcd --- /dev/null +++ b/devtools/shared/resources/tests/test_service_worker.js @@ -0,0 +1,11 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// We don't need any computation in the worker, +// but at least register a fetch listener so that +// we force instantiating the SW when loading the page. +self.onfetch = function(event) { + // do nothing. +}; diff --git a/devtools/shared/resources/tests/test_sw_page.html b/devtools/shared/resources/tests/test_sw_page.html new file mode 100644 index 0000000000..38aad04259 --- /dev/null +++ b/devtools/shared/resources/tests/test_sw_page.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test sw page</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> +<body> +<p>Test sw page</p> + +<script> +"use strict"; + +// Expose a reference to the registration so that tests can unregister it. +window.registrationPromise = navigator.serviceWorker.register("test_sw_page_worker.js"); +</script> +</body> +</html> diff --git a/devtools/shared/resources/tests/test_sw_page_worker.js b/devtools/shared/resources/tests/test_sw_page_worker.js new file mode 100644 index 0000000000..29cda68560 --- /dev/null +++ b/devtools/shared/resources/tests/test_sw_page_worker.js @@ -0,0 +1,5 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// We don't need any computation in the worker, +// just it to be alive diff --git a/devtools/shared/resources/tests/test_worker.js b/devtools/shared/resources/tests/test_worker.js new file mode 100644 index 0000000000..873041fcf0 --- /dev/null +++ b/devtools/shared/resources/tests/test_worker.js @@ -0,0 +1,15 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +console.log("[WORKER] started", globalThis.location.toString(), globalThis); + +globalThis.onmessage = function(e) { + const { type, message } = e.data; + + if (type === "log-in-worker") { + // Printing `e` so we can check that we have an object and not a stringified version + console.log("[WORKER]", message, e); + } +}; diff --git a/devtools/shared/resources/tests/websocket_backend_wsh.py b/devtools/shared/resources/tests/websocket_backend_wsh.py new file mode 100644 index 0000000000..564715114b --- /dev/null +++ b/devtools/shared/resources/tests/websocket_backend_wsh.py @@ -0,0 +1,21 @@ +from __future__ import absolute_import +from mod_pywebsocket import msgutil + + +def web_socket_do_extra_handshake(request): + pass + + +def web_socket_transfer_data(request): + while not request.client_terminated: + resp = msgutil.receive_message(request) + msgutil.send_message(request, resp) + + +def web_socket_passive_closing_handshake(request): + # If we use `pass` here, the `payload` of `frameReceived` which will be happened + # of communication of closing will be `\u0003è`. In order to make the `payload` + # to be empty string, return code and reason explicitly. + code = None + reason = None + return code, reason diff --git a/devtools/shared/resources/tests/websocket_frontend.html b/devtools/shared/resources/tests/websocket_frontend.html new file mode 100644 index 0000000000..ce9b3b93bd --- /dev/null +++ b/devtools/shared/resources/tests/websocket_frontend.html @@ -0,0 +1,39 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + </head> + <body> + <script type="text/javascript"> + /* exported openConnection, closeConnection, sendData */ + "use strict"; + + let webSocket; + function openConnection() { + return new Promise(resolve => { + webSocket = new WebSocket( + "ws://mochi.test:8888/browser/devtools/shared/resources/tests/websocket_backend" + ); + webSocket.onopen = () => { + resolve(); + }; + }); + } + + function closeConnection() { + return new Promise(resolve => { + webSocket.onclose = () => { + resolve(); + }; + webSocket.close(); + }) + } + + function sendData(payload) { + webSocket.send(payload); + } + </script> + </body> +</html> diff --git a/devtools/shared/resources/tests/worker-sources.js b/devtools/shared/resources/tests/worker-sources.js new file mode 100644 index 0000000000..dcf2ed8031 --- /dev/null +++ b/devtools/shared/resources/tests/worker-sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function workerSource() {} |