diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /devtools/shared/commands/resource/tests | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/shared/commands/resource/tests')
70 files changed, 9750 insertions, 0 deletions
diff --git a/devtools/shared/commands/resource/tests/breakpoint_document.html b/devtools/shared/commands/resource/tests/breakpoint_document.html new file mode 100644 index 0000000000..2291094646 --- /dev/null +++ b/devtools/shared/commands/resource/tests/breakpoint_document.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf8"> + <title>Test breakpoint document</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + </head> + <body> + <script> + "use strict"; + /* eslint-disable */ + function testFunction() { + console.log("test Function ran"); + } + function runDebuggerStatement() { + debugger; + } + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/browser.toml b/devtools/shared/commands/resource/tests/browser.toml new file mode 100644 index 0000000000..def009b710 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser.toml @@ -0,0 +1,128 @@ +[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/highlighter-test-actor.js", + "head.js", + "breakpoint_document.html", + "doc_console.html", + "doc_console_iframe.html", + "empty.html", + "network_document.html", + "network_document_navigation.html", + "network_navigation.js", + "early_console_document.html", + "fission_document.html", + "fission_document_workers.html", + "fission_iframe.html", + "fission_iframe_workers.html", + "service-worker-sources.js", + "sources.html", + "sources.js", + "sse_backend.sjs", + "sse_frontend_iframe.html", + "sse_frontend.html", + "style_document.css", + "style_document.html", + "style_iframe.css", + "style_iframe.html", + "stylesheets-nested-iframes.html", + "test_image.png", + "test_service_worker.js", + "test_worker.js", + "websocket_backend_wsh.py", + "websocket_frontend_iframe.html", + "websocket_frontend.html", + "worker-sources.js", +] + +["browser_browser_resources_console_messages.js"] + +["browser_resources_clear_resources.js"] + +["browser_resources_client_caching.js"] + +["browser_resources_console_messages.js"] + +["browser_resources_console_messages_navigation.js"] + +["browser_resources_console_messages_workers.js"] + +["browser_resources_css_changes.js"] + +["browser_resources_css_messages.js"] + +["browser_resources_css_registered_properties.js"] +skip-if = ["!fission"] + +["browser_resources_document_events.js"] +skip-if = [ + "os == 'linux' && bits == 64", # Bug 1715878 +] + +["browser_resources_error_messages.js"] + +["browser_resources_getAllResources.js"] + +["browser_resources_invalid_api_usage.js"] + +["browser_resources_last_private_context_exit.js"] + +["browser_resources_network_event_stacktraces.js"] + +["browser_resources_network_events.js"] + +["browser_resources_network_events_cache.js"] + +["browser_resources_network_events_navigation.js"] + +["browser_resources_network_events_parent_process.js"] + +["browser_resources_platform_messages.js"] + +["browser_resources_reflows.js"] + +["browser_resources_root_node.js"] + +["browser_resources_scope_flag.js"] + +["browser_resources_server_sent_events.js"] + +["browser_resources_several_resources.js"] + +["browser_resources_sources.js"] +skip-if = [ + "os == 'linux' && bits == 64", # Bug 1744565 + "win11_2009", # Bug 1767772 + "apple_catalina", # Bug 1767772 +] + +["browser_resources_stylesheets.js"] + +["browser_resources_stylesheets_header.js"] + +["browser_resources_stylesheets_import.js"] + +["browser_resources_stylesheets_navigation.js"] + +["browser_resources_stylesheets_nested_iframes.js"] + +["browser_resources_target_destroy.js"] + +["browser_resources_target_resources_race.js"] + +["browser_resources_target_switching.js"] + +["browser_resources_thread_states.js"] + +["browser_resources_unwatch_early.js"] + +["browser_resources_watch_unwatch_multiple.js"] + +["browser_resources_websocket.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] diff --git a/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js b/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js new file mode 100644 index 0000000000..1c6c776e64 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CONSOLE_MESSAGE for the whole browser + +const TEST_URL = URL_ROOT_SSL + "early_console_document.html"; + +add_task(async function () { + // Enable Multiprocess Browser Toolbox. + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const d = Date.now(); + const CACHED_MESSAGE_TEXT = `cached-${d}`; + const LIVE_MESSAGE_TEXT = `live-${d}`; + + info( + "Log some messages *before* calling ResourceCommand.watchResources in order to " + + "assert the behavior of already existing messages." + ); + console.log(CACHED_MESSAGE_TEXT); + + info("Wait for existing browser mochitest log"); + const { onResource } = await resourceCommand.waitForNextResource( + resourceCommand.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: false, + predicate({ message }) { + return message.arguments[0] === CACHED_MESSAGE_TEXT; + }, + } + ); + const existingMsg = await onResource; + ok(existingMsg, "The existing log was retrieved"); + is( + existingMsg.isAlreadyExistingResource, + true, + "isAlreadyExistingResource is true for the existing message" + ); + + const { onResource: onMochitestRuntimeLog } = + await resourceCommand.waitForNextResource( + resourceCommand.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: false, + predicate({ message }) { + return message.arguments[0] === LIVE_MESSAGE_TEXT; + }, + } + ); + console.log(LIVE_MESSAGE_TEXT); + + info("Wait for runtime browser mochitest log"); + const runtimeLogResource = await onMochitestRuntimeLog; + ok(runtimeLogResource, "The runtime log was retrieved"); + is( + runtimeLogResource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false for the runtime message" + ); + + const { onResource: onEarlyLog } = await resourceCommand.waitForNextResource( + resourceCommand.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: true, + predicate({ message }) { + return message.arguments[0] === "early-page-log"; + }, + } + ); + await addTab(TEST_URL); + info("Wait for early page log"); + const earlyResource = await onEarlyLog; + ok(earlyResource, "The early page log was retrieved"); + is( + earlyResource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false for the early message" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js b/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js new file mode 100644 index 0000000000..44068cb141 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the clearResources function of the ResourceCommand + +add_task(async () => { + const tab = await addTab(`${URL_ROOT_SSL}empty.html`); + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Assert the initial no of resources"); + assertNoOfResources(resourceCommand, 0, 0); + + const onAvailable = () => {}; + const onUpdated = () => {}; + + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { onAvailable, onUpdated } + ); + + info("Log some messages"); + await logConsoleMessages(tab.linkedBrowser, ["log1", "log2", "log3"]); + + info("Trigger some network requests"); + const EXAMPLE_DOMAIN = "https://example.com/"; + await triggerNetworkRequests(tab.linkedBrowser, [ + `await fetch("${EXAMPLE_DOMAIN}/request1.html", { method: "GET" });`, + `await fetch("${EXAMPLE_DOMAIN}/request2.html", { method: "GET" });`, + ]); + + assertNoOfResources(resourceCommand, 3, 2); + + info("Clear the network event resources"); + await resourceCommand.clearResources([resourceCommand.TYPES.NETWORK_EVENT]); + assertNoOfResources(resourceCommand, 3, 0); + + info("Clear the console message resources"); + await resourceCommand.clearResources([resourceCommand.TYPES.CONSOLE_MESSAGE]); + assertNoOfResources(resourceCommand, 0, 0); + + resourceCommand.unwatchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { onAvailable, onUpdated, ignoreExistingResources: true } + ); + + targetCommand.destroy(); + await client.close(); +}); + +function assertNoOfResources( + resourceCommand, + expectedNoOfConsoleMessageResources, + expectedNoOfNetworkEventResources +) { + const actualNoOfConsoleMessageResources = resourceCommand.getAllResources( + resourceCommand.TYPES.CONSOLE_MESSAGE + ).length; + is( + actualNoOfConsoleMessageResources, + expectedNoOfConsoleMessageResources, + `There are ${actualNoOfConsoleMessageResources} console messages resources` + ); + + const actualNoOfNetworkEventResources = resourceCommand.getAllResources( + resourceCommand.TYPES.NETWORK_EVENT + ).length; + is( + actualNoOfNetworkEventResources, + expectedNoOfNetworkEventResources, + `There are ${actualNoOfNetworkEventResources} network event resources` + ); +} + +function logConsoleMessages(browser, messages) { + return SpecialPowers.spawn(browser, [messages], innerMessages => { + for (const message of innerMessages) { + content.console.log(message); + } + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_client_caching.js b/devtools/shared/commands/resource/tests/browser_resources_client_caching.js new file mode 100644 index 0000000000..ae398f73cc --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_client_caching.js @@ -0,0 +1,380 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the cache mechanism of the ResourceCommand. + +const TEST_URI = "data:text/html;charset=utf-8,<!DOCTYPE html>Cache Test"; + +add_task(async function () { + info("Test whether multiple listener can get same cached resources"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const messages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, messages); + + info("Register first listener"); + const cachedResources1 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + ok(areExistingResources, "All resources are already existing ones"); + cachedResources1.push(...resources); + }, + } + ); + + info("Register second listener"); + const cachedResources2 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + ok(areExistingResources, "All resources are already existing ones"); + cachedResources2.push(...resources); + }, + } + ); + + assertContents(cachedResources1, messages); + assertResources(cachedResources2, cachedResources1); + + targetCommand.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, resourceCommand, targetCommand } = await initResourceCommand( + 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 = []; + // We first get notified about existing resources + let shouldBeExistingResources = true; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + is( + areExistingResources, + shouldBeExistingResources, + "areExistingResources flag is correct" + ); + availableResources.push(...resources); + }, + } + ); + // Then, we are notified about, new, live ones + shouldBeExistingResources = false; + + 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 resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + ok(areExistingResources, "All resources are already existing ones"); + cachedResources.push(...resources); + }, + } + ); + + assertContents(availableResources, allMessages); + assertResources(cachedResources, availableResources); + + targetCommand.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, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener"); + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: () => {}, + } + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab); + + info("Register second listener"); + const cachedResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + is(cachedResources.length, 0, "The cache in ResourceCommand is cleared"); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info("Test with multiple resource types"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Register first listener to get all available resources"); + const availableResources = []; + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.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 resourceCommand.watchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.ERROR_MESSAGE, + ], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + assertResources(cachedResources, availableResources); + + targetCommand.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, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener"); + const cachedResources1 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources1.push(...resources), + ignoreExistingResources: isFirstListenerIgnoreExisting, + } + ); + + info("Register second listener"); + const cachedResources2 = []; + await resourceCommand.watchResources( + [resourceCommand.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); + + targetCommand.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, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Register first listener to get all available resources"); + const availableResources = []; + let onAvailableCallCount = 0; + const onAvailable = resources => { + ok( + !!resources.length, + "onAvailable is called with a non empty resources array" + ); + availableResources.push(...resources); + onAvailableCallCount++; + }; + + await resourceCommand.watchResources( + [resourceCommand.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" + ); + + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); + targetCommand.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]; + Assert.strictEqual( + 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/commands/resource/tests/browser_resources_console_messages.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages.js new file mode 100644 index 0000000000..6f02cd5a77 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages.js @@ -0,0 +1,623 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand 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 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, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info( + "Log some messages *before* calling ResourceCommand.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) { + is( + resource.resourceType, + resourceCommand.TYPES.CONSOLE_MESSAGE, + "Received a message" + ); + ok(resource.message, "message is wrapped into a message attribute"); + const isCachedMessage = !!expectedExistingCalls.length; + const expected = ( + isCachedMessage ? expectedExistingCalls : expectedRuntimeCalls + ).shift(); + checkConsoleAPICall(resource.message, expected); + is( + resource.isAlreadyExistingResource, + isCachedMessage, + "isAlreadyExistingResource has the expected value" + ); + + if (!expectedRuntimeCalls.length) { + runtimeDoneResolve(); + } + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + is( + expectedExistingCalls.length, + 0, + "Got the expected number of existing messages" + ); + + info( + "Now log messages *after* the call to ResourceCommand.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" + ); + + targetCommand.destroy(); + await client.close(); +} + +async function testTabConsoleMessagesResourcesWithIgnoreExistingResources( + executeInIframe +) { + info("Test ignoreExistingResources option for console messages"); + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info( + "Check whether onAvailable will not be called with existing console messages" + ); + await logExistingMessages(tab.linkedBrowser, executeInIframe); + + const availableResources = []; + await resourceCommand.watchResources( + [resourceCommand.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() || isEveryFrameTargetEnabled()) + ? targetCommand + .getAllTargets([targetCommand.TYPES.FRAME]) + .find(target => target.url == IFRAME_URL) + : targetCommand.targetFront; + for (let i = 0; i < expectedRuntimeConsoleCalls.length; i++) { + const resource = availableResources[i]; + const { message, targetFront } = resource; + is( + targetFront, + expectedTargetFront, + "The targetFront property is the expected one" + ); + const expected = expectedRuntimeConsoleCalls[i]; + checkConsoleAPICall(message, expected); + is( + resource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false since we're ignoring existing resources" + ); + } + + targetCommand.destroy(); + await client.close(); +} + +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+$/; +// timeStamp are the result of a number in microsecond divided by 1000. +// so we can't expect a precise number of decimals, or even if there would +// be decimals at all. +const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/; + +function getExpectedExistingConsoleCalls(documentFilename) { + const defaultProperties = { + filename: documentFilename, + columnNumber: NUMBER_REGEX, + lineNumber: NUMBER_REGEX, + timeStamp: FRACTIONAL_NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + chromeContext: undefined, + counter: undefined, + prefix: undefined, + private: undefined, + stacktrace: undefined, + styles: undefined, + timer: undefined, + }; + + return [ + { + ...defaultProperties, + level: "log", + arguments: ["foobarBaz-log", { type: "undefined" }], + }, + { + ...defaultProperties, + level: "info", + arguments: ["foobarBaz-info", { type: "null" }], + }, + { + ...defaultProperties, + level: "warn", + 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: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + }, + ]; + + const defaultProperties = { + filename: documentFilename, + columnNumber: NUMBER_REGEX, + lineNumber: NUMBER_REGEX, + timeStamp: FRACTIONAL_NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + chromeContext: undefined, + counter: undefined, + prefix: undefined, + private: undefined, + stacktrace: undefined, + styles: undefined, + timer: undefined, + }; + + return [ + { + ...defaultProperties, + level: "log", + arguments: ["foobarBaz-log", { type: "undefined" }], + }, + { + ...defaultProperties, + level: "log", + arguments: ["Float from not a number: NaN"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["Float from string: 1.200000"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["Float from number: 1.300000"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["BigInt 123 and 456"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["message with ", "style"], + styles: ["color: blue;", "background: red; font-size: 2em;"], + }, + { + ...defaultProperties, + level: "info", + arguments: ["foobarBaz-info", { type: "null" }], + }, + { + ...defaultProperties, + level: "warn", + arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }], + }, + { + ...defaultProperties, + level: "debug", + arguments: [{ type: "null" }], + }, + { + ...defaultProperties, + level: "trace", + stacktrace: [ + { + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + }, + ...defaultStackFrames, + ], + }, + { + ...defaultProperties, + level: "dir", + arguments: [ + { + type: "object", + actor: /[a-z]/, + class: "HTMLDocument", + }, + { + type: "object", + actor: /[a-z]/, + class: "Location", + }, + ], + }, + { + ...defaultProperties, + level: "log", + arguments: [ + "foo", + { + type: "longString", + initial: longString.substring( + 0, + DevToolsServer.LONG_STRING_INITIAL_LENGTH + ), + length: longString.length, + actor: /[a-z]/, + }, + ], + }, + { + ...defaultProperties, + level: "count", + arguments: ["myCounter"], + counter: { + count: 1, + label: "myCounter", + }, + }, + { + ...defaultProperties, + level: "count", + arguments: ["myCounter"], + counter: { + count: 2, + label: "myCounter", + }, + }, + { + ...defaultProperties, + level: "count", + arguments: ["default"], + counter: { + count: 1, + label: "default", + }, + }, + { + ...defaultProperties, + level: "countReset", + arguments: ["myCounter"], + counter: { + count: 0, + label: "myCounter", + }, + }, + { + ...defaultProperties, + level: "countReset", + arguments: ["unknownCounter"], + counter: { + error: "counterDoesntExist", + label: "unknownCounter", + }, + }, + { + ...defaultProperties, + level: "time", + arguments: ["myTimer"], + timer: { + name: "myTimer", + }, + }, + { + ...defaultProperties, + level: "time", + arguments: ["myTimer"], + timer: { + name: "myTimer", + error: "timerAlreadyExists", + }, + }, + { + ...defaultProperties, + level: "timeLog", + arguments: ["myTimer"], + timer: { + name: "myTimer", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "timeEnd", + arguments: ["myTimer"], + timer: { + name: "myTimer", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "time", + arguments: ["default"], + timer: { + name: "default", + }, + }, + { + ...defaultProperties, + level: "timeLog", + arguments: ["default"], + timer: { + name: "default", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "timeEnd", + arguments: ["default"], + timer: { + name: "default", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "timeLog", + arguments: ["unknownTimer"], + timer: { + name: "unknownTimer", + error: "timerDoesntExist", + }, + }, + { + ...defaultProperties, + level: "timeEnd", + arguments: ["unknownTimer"], + timer: { + name: "unknownTimer", + error: "timerDoesntExist", + }, + }, + { + ...defaultProperties, + level: "error", + 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, + ], + }, + { + ...defaultProperties, + level: "log", + filename: + "chrome://mochitests/content/browser/devtools/shared/commands/resource/tests/browser_resources_console_messages.js", + arguments: [ + { + type: "object", + actor: /[a-z]/, + class: "Restricted", + }, + ], + chromeContext: true, + }, + ]; +} + +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.log("BigInt %d and %i", 123n, 456n); + console.log( + "%cmessage with %cstyle", + "color: blue;", + "background: red; font-size: 2em;" + ); + + console.info("foobarBaz-info", null); + console.warn("foobarBaz-warn", document.documentElement); + console.debug(null); + console.trace(); + console.dir(document, location); + console.log("foo", _longString); + + console.count("myCounter"); + console.count("myCounter"); + console.count(); + console.countReset("myCounter"); + // will cause warnings because unknownCounter doesn't exist + console.countReset("unknownCounter"); + + console.time("myTimer"); + // will cause warning because myTimer already exist + console.time("myTimer"); + console.timeLog("myTimer"); + console.timeEnd("myTimer"); + console.time(); + console.timeLog(); + console.timeEnd(); + // // will cause warnings because unknownTimer doesn't exist + console.timeLog("unknownTimer"); + console.timeEnd("unknownTimer"); + + function fromAsmJS() { + console.error("foobarBaz-asmjs-error", undefined); + } + + (function (global, foreign) { + "use asm"; + function inAsmJS2() { + foreign.fromAsmJS(); + } + function inAsmJS1() { + inAsmJS2(); + } + return inAsmJS1; + })(null, { 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/commands/resource/tests/browser_resources_console_messages_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js new file mode 100644 index 0000000000..3d6fc697da --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js @@ -0,0 +1,190 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the resource command API around CONSOLE_MESSAGE when navigating +// tab and inner iframes to distinct origin/processes. + +const TEST_URL = URL_ROOT_COM_SSL + "doc_console.html"; +const TEST_IFRAME_URL = URL_ROOT_ORG_SSL + "doc_console_iframe.html"; +const TEST_DOMAIN = "https://example.org"; +add_task(async function () { + const START_URL = "data:text/html;charset=utf-8,foo"; + const tab = await addTab(START_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + await testCrossProcessTabNavigation(tab.linkedBrowser, resourceCommand); + await testCrossProcessIframeNavigation(tab.linkedBrowser, resourceCommand); + + targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +}); + +async function testCrossProcessTabNavigation(browser, resourceCommand) { + info( + "Navigate the top level document from data: URI to a https document including remote iframes" + ); + + let doneResolve; + const messages = []; + const onConsoleLogsComplete = new Promise(resolve => (doneResolve = resolve)); + + const onAvailable = resources => { + messages.push(...resources); + if (messages.length == 2) { + doneResolve(); + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + + const onLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, TEST_URL); + await onLoaded; + + info("Wait for log message"); + await onConsoleLogsComplete; + + // messages are coming from different targets so the order isn't guaranteed + const topLevelMessageResource = messages.find(resource => + resource.message.filename.startsWith(URL_ROOT_COM_SSL) + ); + const iframeMessage = messages.find(resource => + resource.message.filename.startsWith("data:") + ); + + assertConsoleMessage(resourceCommand, topLevelMessageResource, { + targetFront: resourceCommand.targetCommand.targetFront, + messageText: "top-level document log", + }); + assertConsoleMessage(resourceCommand, iframeMessage, { + targetFront: isEveryFrameTargetEnabled + ? resourceCommand.targetCommand + .getAllTargets([resourceCommand.targetCommand.TYPES.FRAME]) + .find(t => t.url.startsWith("data:")) + : resourceCommand.targetCommand.targetFront, + messageText: "data url data log", + }); + + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); +} + +async function testCrossProcessIframeNavigation(browser, resourceCommand) { + info("Navigate an inner iframe from data: URI to a https remote URL"); + + let doneResolve; + const messages = []; + const onConsoleLogsComplete = new Promise(resolve => (doneResolve = resolve)); + + const onAvailable = resources => { + messages.push( + ...resources.filter( + r => !r.message.arguments[0].startsWith("[WORKER] started") + ) + ); + if (messages.length == 3) { + doneResolve(); + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + + // messages are coming from different targets so the order isn't guaranteed + const topLevelMessageResource = messages.find(resource => + resource.message.arguments[0].startsWith("top-level") + ); + const dataUrlMessageResource = messages.find(resource => + resource.message.arguments[0].startsWith("data url") + ); + + // Assert cached messages from the previous top document + assertConsoleMessage(resourceCommand, topLevelMessageResource, { + messageText: "top-level document log", + }); + assertConsoleMessage(resourceCommand, dataUrlMessageResource, { + messageText: "data url data log", + }); + + // Navigate the iframe to another origin/process + await SpecialPowers.spawn(browser, [TEST_IFRAME_URL], function (iframeUrl) { + const iframe = content.document.querySelector("iframe"); + iframe.src = iframeUrl; + }); + + info("Wait for log message"); + await onConsoleLogsComplete; + + // iframeTarget will be different if Fission is on or off + const iframeTarget = await getIframeTargetFront( + resourceCommand.targetCommand + ); + + const iframeMessageResource = messages.find(resource => + resource.message.arguments[0].endsWith("iframe log") + ); + assertConsoleMessage(resourceCommand, iframeMessageResource, { + messageText: `${TEST_DOMAIN} iframe log`, + targetFront: iframeTarget, + }); + + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); +} + +function assertConsoleMessage(resourceCommand, messageResource, expected) { + is( + messageResource.resourceType, + resourceCommand.TYPES.CONSOLE_MESSAGE, + "Resource is a console message" + ); + ok(messageResource.message, "message is wrapped into a message attribute"); + if (expected.targetFront) { + is( + messageResource.targetFront, + expected.targetFront, + "Message has the correct target front" + ); + } + is( + messageResource.message.arguments[0], + expected.messageText, + "The correct type of message" + ); +} + +async function getIframeTargetFront(targetCommand) { + // If Fission/EFT is enabled, the iframe will have a dedicated target, + // otherwise it will be debuggable via the top level target. + if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) { + return targetCommand.targetFront; + } + const frameTargets = targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + const browsingContextID = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + return content.document.querySelector("iframe").browsingContext.id; + } + ); + const iframeTarget = frameTargets.find(target => { + return target.browsingContextID == browsingContextID; + }); + ok(iframeTarget, "Found the iframe target front"); + return iframeTarget; +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js new file mode 100644 index 0000000000..4b10f1d2e4 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js @@ -0,0 +1,257 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CONSOLE_MESSAGE in workers + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document_workers.html"; +const WORKER_FILE = "test_worker.js"; +const IFRAME_FILE = `${URL_ROOT_ORG_SSL}fission_iframe_workers.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, resourceCommand, targetCommand } = await initResourceCommand( + 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(); + } + }; + targetCommand.watchTargets({ + types: [targetCommand.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 resourceCommand.watchResources( + [resourceCommand.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, { + expectedUrl: `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker`, + isAlreadyExistingResource: true, + }); + checkStartWorkerLogMessage(startLogFromWorkerInIframe, { + expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe`, + isAlreadyExistingResource: true, + }); + let messageCount = resources.length; + + info( + "Now log messages *after* the call to ResourceCommand.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, { + expectedUrl: `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker`, + }); + checkStartWorkerLogMessage(startLogFromSpawnedWorkerInIframe, { + expectedUrl: `${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, { + expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-second-iframe`, + }); + + targetCommand.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, isAlreadyExistingResource = false } +) { + 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" + ); + is( + resource.isAlreadyExistingResource, + isAlreadyExistingResource, + "Resource has expected value for isAlreadyExistingResource" + ); +} + +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" + ); + is( + resource.isAlreadyExistingResource, + false, + "Resource has expected value for isAlreadyExistingResource" + ); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_css_changes.js b/devtools/shared/commands/resource/tests/browser_resources_css_changes.js new file mode 100644 index 0000000000..22b11a8186 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_css_changes.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CSS_CHANGE. + +add_task(async function () { + // Open a test tab + const tab = await addTab( + "data:text/html,<body style='color: lime;'>CSS Changes</body>" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // CSS_CHANGE watcher doesn't record modification made before watching, + // so we have to start watching before doing any DOM mutation. + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], { + onAvailable: () => {}, + }); + + const { walker } = await targetCommand.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 ResourceCommand catches CSS change that fired before starting to watch" + ); + await setProperty(style.rule, 0, "color", "black"); + + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.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 ResourceCommand catches CSS changes after the property was renamed and updated" + ); + + // RuleRewriter:apply will not support a simultaneous rename + setProperty. + // Doing so would send inconsistent arguments to StyleRuleActor:setRuleText, + // the CSS text for the rule will not match the list of modifications, which + // would desynchronize the Changes view. Thankfully this scenario should not + // happen when using the UI to update the rules. + await renameProperty(style.rule, 0, "color", "background-color"); + await waitUntil(() => availableResources.length === 2); + assertResource( + availableResources[1], + { index: 0, property: "background-color", value: "black" }, + { index: 0, property: "color", value: "black" } + ); + + await setProperty(style.rule, 0, "background-color", "pink"); + await waitUntil(() => availableResources.length === 3); + assertResource( + availableResources[2], + { index: 0, property: "background-color", value: "pink" }, + { index: 0, property: "background-color", value: "black" } + ); + + info("Check whether ResourceCommand catches CSS change of disabling"); + await setPropertyEnabled(style.rule, 0, "background-color", false); + await waitUntil(() => availableResources.length === 4); + assertResource(availableResources[3], null, { + index: 0, + property: "background-color", + value: "pink", + }); + + info("Check whether ResourceCommand catches CSS change of new property"); + await createProperty(style.rule, 1, "font-size", "100px"); + await waitUntil(() => availableResources.length === 5); + assertResource( + availableResources[4], + { index: 1, property: "font-size", value: "100px" }, + null + ); + + info("Check whether ResourceCommand sends all resources added in this test"); + const existingResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], { + onAvailable: resources => existingResources.push(...resources), + }); + await waitUntil(() => existingResources.length === 5); + 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"); + is(availableResources[4], existingResources[4], "4th resource is correct"); + + targetCommand.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 renameProperty(rule, index, oldName, newName, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.renameProperty(index, oldName, newName); + 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/commands/resource/tests/browser_resources_css_messages.js b/devtools/shared/commands/resource/tests/browser_resources_css_messages.js new file mode 100644 index 0000000000..1b4b56cd4f --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_css_messages.js @@ -0,0 +1,212 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CSS_MESSAGE +// Reproduces the CSS message assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html + +const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js"); + +// 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 { + body { + 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, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const receivedMessages = []; + const { onAvailable, onAllMessagesReceived } = setupOnAvailableFunction( + targetCommand, + receivedMessages, + false + ); + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_MESSAGE], { + onAvailable, + }); + + info( + "Now log CSS warning *after* the call to ResourceCommand.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(); + targetCommand.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. + await reloadBrowser(); + // 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, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const receivedMessages = []; + const { onAvailable } = setupOnAvailableFunction( + targetCommand, + receivedMessages, + true + ); + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_MESSAGE], { + onAvailable, + }); + is(receivedMessages.length, 3, "Cached messages were retrieved as expected"); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +function setupOnAvailableFunction( + targetCommand, + receivedMessages, + isAlreadyExistingResource +) { + // timeStamp are the result of a number in microsecond divided by 1000. + // so we can't expect a precise number of decimals, or even if there would + // be decimals at all. + const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/; + + // 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: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + }, + cssSelectors: ":is(html) body", + isAlreadyExistingResource, + }, + { + pageError: { + errorMessage: /Error in parsing value for โwidthโ/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + }, + isAlreadyExistingResource, + }, + { + pageError: { + errorMessage: /Error in parsing value for โheightโ/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + }, + isAlreadyExistingResource, + }, + ]; + + let done; + const onAllMessagesReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + const { pageError } = resource; + + is( + resource.targetFront, + targetCommand.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(resource); + + 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/commands/resource/tests/browser_resources_css_registered_properties.js b/devtools/shared/commands/resource/tests/browser_resources_css_registered_properties.js new file mode 100644 index 0000000000..1429b55167 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_css_registered_properties.js @@ -0,0 +1,384 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CSS_REGISTERED_PROPERTIES. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const IFRAME_URL = `https://example.org/document-builder.sjs?html=${encodeURIComponent(` + <style> + @property --css-a { + syntax: "<color>"; + inherits: true; + initial-value: gold; + } + </style> + <script> + CSS.registerProperty({ + name: "--js-a", + syntax: "<length>", + inherits: true, + initialValue: "20px" + }); + </script> + <h1>iframe</h1> +`)}`; + +const TEST_URL = `https://example.org/document-builder.sjs?html= + <head> + <style> + @property --css-a { + syntax: "*"; + inherits: false; + } + + @property --css-b { + syntax: "<color>"; + inherits: true; + initial-value: tomato; + } + </style> + <script> + CSS.registerProperty({ + name: "--js-a", + syntax: "*", + inherits: false, + }); + CSS.registerProperty({ + name: "--js-b", + syntax: "<length>", + inherits: true, + initialValue: "10px" + }); + </script> + </head> + <h1>CSS_REGISTERED_PROPERTIES</h1> + <iframe src="${encodeURIComponent(IFRAME_URL)}"></iframe>`; + +add_task(async function () { + await pushPref("layout.css.properties-and-values.enabled", true); + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // Wait for targets + await targetCommand.startListening(); + const targets = []; + const onAvailable = ({ targetFront }) => targets.push(targetFront); + await targetCommand.watchTargets({ + types: [targetCommand.TYPES.FRAME], + onAvailable, + }); + await waitFor(() => targets.length === 2); + const [topLevelTarget, iframeTarget] = targets.sort((a, b) => + a.isTopLevel ? -1 : 1 + ); + + // Watching for new stylesheets shouldn't be + const stylesheets = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => stylesheets.push(...resources), + ignoreExistingResources: true, + }); + + info("Check that we get existing registered properties"); + const availableResources = []; + const updatedResources = []; + const destroyedResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CSS_REGISTERED_PROPERTIES], + { + onAvailable: resources => availableResources.push(...resources), + onUpdated: resources => updatedResources.push(...resources), + onDestroyed: resources => destroyedResources.push(...resources), + } + ); + + is( + availableResources.length, + 6, + "The 6 existing registered properties where retrieved" + ); + + // Sort resources so we get them alphabetically ordered by their name, with the ones for + // the top level target displayed first. + availableResources.sort((a, b) => { + if (a.targetFront !== b.targetFront) { + return a.targetFront.isTopLevel ? -1 : 1; + } + return a.name < b.name ? -1 : 1; + }); + + assertResource(availableResources[0], { + name: "--css-a", + syntax: "*", + inherits: false, + initialValue: null, + fromJS: false, + targetFront: topLevelTarget, + }); + assertResource(availableResources[1], { + name: "--css-b", + syntax: "<color>", + inherits: true, + initialValue: "tomato", + fromJS: false, + targetFront: topLevelTarget, + }); + assertResource(availableResources[2], { + name: "--js-a", + syntax: "*", + inherits: false, + initialValue: null, + fromJS: true, + targetFront: topLevelTarget, + }); + assertResource(availableResources[3], { + name: "--js-b", + syntax: "<length>", + inherits: true, + initialValue: "10px", + fromJS: true, + targetFront: topLevelTarget, + }); + assertResource(availableResources[4], { + name: "--css-a", + syntax: "<color>", + inherits: true, + initialValue: "gold", + fromJS: false, + targetFront: iframeTarget, + }); + assertResource(availableResources[5], { + name: "--js-a", + syntax: "<length>", + inherits: true, + initialValue: "20px", + fromJS: true, + targetFront: iframeTarget, + }); + + info("Check that we didn't get notified about existing stylesheets"); + // wait a bit so we'd have the time to be notified about stylesheet resources + await wait(500); + is( + stylesheets.length, + 0, + "Watching for registered properties does not notify about existing stylesheets resources" + ); + + info("Check that we get properties from new stylesheets"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const s = content.document.createElement("style"); + s.textContent = ` + @property --css-c { + syntax: "<custom-ident>"; + inherits: true; + initial-value: custom; + } + + @property --css-d { + syntax: "big | bigger"; + inherits: true; + initial-value: big; + } + `; + content.document.head.append(s); + }); + + info("Wait for registered properties to be available"); + await waitFor(() => availableResources.length === 8); + ok(true, "Got notified about 2 new registered properties"); + assertResource(availableResources[6], { + name: "--css-c", + syntax: "<custom-ident>", + inherits: true, + initialValue: "custom", + fromJS: false, + targetFront: topLevelTarget, + }); + assertResource(availableResources[7], { + name: "--css-d", + syntax: "big | bigger", + inherits: true, + initialValue: "big", + fromJS: false, + targetFront: topLevelTarget, + }); + + info("Wait to be notified about the new stylesheet"); + await waitFor(() => stylesheets.length === 1); + ok(true, "we do get notified about stylesheets"); + + info( + "Check that we get notified about properties registered via CSS.registerProperty" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.CSS.registerProperty({ + name: "--js-c", + syntax: "*", + inherits: false, + initialValue: 42, + }); + content.CSS.registerProperty({ + name: "--js-d", + syntax: "<color>#", + inherits: true, + initialValue: "blue,cyan", + }); + }); + + await waitFor(() => availableResources.length === 10); + ok(true, "Got notified about 2 new registered properties"); + assertResource(availableResources[8], { + name: "--js-c", + syntax: "*", + inherits: false, + initialValue: "42", + fromJS: true, + targetFront: topLevelTarget, + }); + assertResource(availableResources[9], { + name: "--js-d", + syntax: "<color>#", + inherits: true, + initialValue: "blue,cyan", + fromJS: true, + targetFront: topLevelTarget, + }); + + info( + "Check that we get notified about properties registered via CSS.registerProperty in iframe" + ); + const iframeBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); + + await SpecialPowers.spawn(iframeBrowsingContext, [], () => { + content.CSS.registerProperty({ + name: "--js-iframe", + syntax: "<color>#", + inherits: true, + initialValue: "red,salmon", + }); + }); + + await waitFor(() => availableResources.length === 11); + ok(true, "Got notified about 2 new registered properties"); + assertResource(availableResources[10], { + name: "--js-iframe", + syntax: "<color>#", + inherits: true, + initialValue: "red,salmon", + fromJS: true, + targetFront: iframeTarget, + }); + + info( + "Check that we get notified about destroyed properties when removing stylesheet" + ); + // sanity check + is(destroyedResources.length, 0, "No destroyed resources yet"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.document.querySelector("style").remove(); + }); + await waitFor(() => destroyedResources.length == 2); + ok(true, "We got notified about destroyed resources"); + destroyedResources.sort((a, b) => a < b); + is( + destroyedResources[0].resourceType, + ResourceCommand.TYPES.CSS_REGISTERED_PROPERTIES, + "resource type is correct" + ); + is( + destroyedResources[0].resourceId, + `${topLevelTarget.actorID}:css-registered-property:--css-a`, + "expected css property was destroyed" + ); + is( + destroyedResources[1].resourceType, + ResourceCommand.TYPES.CSS_REGISTERED_PROPERTIES, + "resource type is correct" + ); + is( + destroyedResources[1].resourceId, + `${topLevelTarget.actorID}:css-registered-property:--css-b`, + "expected css property was destroyed" + ); + + info( + "Check that we get notified about updated properties when modifying stylesheet" + ); + is(updatedResources.length, 0, "No updated resources yet"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.document.querySelector("style").textContent = ` + /* not updated */ + @property --css-c { + syntax: "<custom-ident>"; + inherits: true; + initial-value: custom; + } + + @property --css-d { + syntax: "big | bigger"; + inherits: true; + /* only change initial value (was big) */ + initial-value: bigger; + } + + /* add a new property */ + @property --css-e { + syntax: "<color>"; + inherits: false; + initial-value: green; + } + `; + }); + await waitFor(() => updatedResources.length === 1); + ok(true, "One property was updated"); + assertResource(updatedResources[0].resource, { + name: "--css-d", + syntax: "big | bigger", + inherits: true, + initialValue: "bigger", + fromJS: false, + targetFront: topLevelTarget, + }); + + await waitFor(() => availableResources.length === 12); + ok(true, "We got notified about the new property"); + assertResource(availableResources.at(-1), { + name: "--css-e", + syntax: "<color>", + inherits: false, + initialValue: "green", + fromJS: false, + targetFront: topLevelTarget, + }); + + await client.close(); +}); + +async function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.CSS_REGISTERED_PROPERTIES, + "Resource type is correct" + ); + is(resource.name, expected.name, "name is correct"); + is(resource.syntax, expected.syntax, "syntax is correct"); + is(resource.inherits, expected.inherits, "inherits is correct"); + is(resource.initialValue, expected.initialValue, "initialValue is correct"); + is(resource.fromJS, expected.fromJS, "fromJS is correct"); + is( + resource.targetFront, + expected.targetFront, + "resource is associated with expected target" + ); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_document_events.js b/devtools/shared/commands/resource/tests/browser_resources_document_events.js new file mode 100644 index 0000000000..4692cba1ed --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_document_events.js @@ -0,0 +1,720 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around DOCUMENT_EVENT + +add_task(async function () { + await testDocumentEventResources(); + await testDocumentEventResourcesWithIgnoreExistingResources(); + await testDomCompleteWithOverloadedConsole(); + await testIframeNavigation(); + await testBfCacheNavigation(); + await testDomCompleteWithWindowStop(); + await testCrossOriginNavigation(); +}); + +async function testDocumentEventResources() { + info("Test ResourceCommand for DOCUMENT_EVENT"); + + // Open a test tab + const title = "DocumentEventsTitle"; + const url = `data:text/html,<title>${title}</title>Document Events`; + const tab = await addTab(url); + + const listener = new ResourceListener(); + const { commands } = await initResourceCommand(tab); + + info( + "Check whether the document events are fired correctly even when the document was already loaded" + ); + const onLoadingAtInit = listener.once("dom-loading"); + const onInteractiveAtInit = listener.once("dom-interactive"); + const onCompleteAtInit = listener.once("dom-complete"); + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: parameters => listener.dispatch(parameters), + } + ); + await assertPromises( + commands, + // targetBeforeNavigation is only used when there is a will-navigate and a navigate, but there is none here + null, + // As we started watching on an already loaded document, and no navigation happened since we called watchResources, + // we don't have any will-navigate event + null, + onLoadingAtInit, + onInteractiveAtInit, + onCompleteAtInit + ); + ok( + true, + "Document events are fired even when the document was already loaded" + ); + let domLoadingResource = await onLoadingAtInit; + + is( + domLoadingResource.url, + url, + `resource ${domLoadingResource.name} has expected url` + ); + is( + domLoadingResource.title, + undefined, + `resource ${domLoadingResource.name} does not have a title property` + ); + + let domInteractiveResource = await onInteractiveAtInit; + is( + domInteractiveResource.url, + url, + `resource ${domInteractiveResource.name} has expected url` + ); + is( + domInteractiveResource.title, + title, + `resource ${domInteractiveResource.name} has expected title` + ); + let domCompleteResource = await onCompleteAtInit; + is( + domCompleteResource.url, + undefined, + `resource ${domCompleteResource.name} does not have a url property` + ); + is( + domCompleteResource.title, + undefined, + `resource ${domCompleteResource.name} does not have a title property` + ); + + info("Check whether the document events are fired correctly when reloading"); + const onWillNavigate = listener.once("will-navigate"); + const onLoadingAtReloaded = listener.once("dom-loading"); + const onInteractiveAtReloaded = listener.once("dom-interactive"); + const onCompleteAtReloaded = listener.once("dom-complete"); + const targetBeforeNavigation = commands.targetCommand.targetFront; + gBrowser.reloadTab(tab); + await assertPromises( + commands, + targetBeforeNavigation, + onWillNavigate, + onLoadingAtReloaded, + onInteractiveAtReloaded, + onCompleteAtReloaded + ); + ok(true, "Document events are fired after reloading"); + + domLoadingResource = await onLoadingAtReloaded; + is( + domLoadingResource.url, + url, + `resource ${domLoadingResource.name} has expected url after reloading` + ); + is( + domLoadingResource.title, + undefined, + `resource ${domLoadingResource.name} does not have a title property after reloading` + ); + + domInteractiveResource = await onInteractiveAtInit; + is( + domInteractiveResource.url, + url, + `resource ${domInteractiveResource.name} has url property after reloading` + ); + is( + domInteractiveResource.title, + title, + `resource ${domInteractiveResource.name} has expected title after reloading` + ); + domCompleteResource = await onCompleteAtInit; + is( + domCompleteResource.url, + undefined, + `resource ${domCompleteResource.name} does not have a url property after reloading` + ); + is( + domCompleteResource.title, + undefined, + `resource ${domCompleteResource.name} does not have a title property after reloading` + ); + + await commands.destroy(); +} + +async function testDocumentEventResourcesWithIgnoreExistingResources() { + info("Test ignoreExistingResources option for DOCUMENT_EVENT"); + + const tab = await addTab("data:text/html,Document Events"); + + const { commands } = await initResourceCommand(tab); + + info("Check whether the existing document events will not be fired"); + const documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => documentEvents.push(...resources), + ignoreExistingResources: true, + } + ); + is(documentEvents.length, 0, "Existing document events are not fired"); + + info("Check whether the future document events are fired"); + const targetBeforeNavigation = commands.targetCommand.targetFront; + gBrowser.reloadTab(tab); + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length === 4); + assertEvents({ commands, targetBeforeNavigation, documentEvents }); + + await commands.destroy(); +} + +async function testIframeNavigation() { + info("Test iframe navigations for DOCUMENT_EVENT"); + + const tab = await addTab( + 'https://example.com/document-builder.sjs?html=<iframe src="https://example.net/document-builder.sjs?html=net"></iframe>' + ); + const secondPageUrl = "https://example.org/document-builder.sjs?html=org"; + + const { commands } = await initResourceCommand(tab); + + let documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => documentEvents.push(...resources), + } + ); + let iframeTarget; + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + is( + documentEvents.length, + 6, + "With fission/EFT, we get two targets and two sets of events: dom-loading, dom-interactive, dom-complete" + ); + [, iframeTarget] = await commands.targetCommand.getAllTargets([ + commands.targetCommand.TYPES.FRAME, + ]); + // Filter out each target events as their order to be random between the two targets + const topTargetEvents = documentEvents.filter( + r => r.targetFront == commands.targetCommand.targetFront + ); + const iframeTargetEvents = documentEvents.filter( + r => r.targetFront != commands.targetCommand.targetFront + ); + assertEvents({ + commands, + documentEvents: [null /* no will-navigate */, ...topTargetEvents], + }); + assertEvents({ + commands, + documentEvents: [null /* no will-navigate */, ...iframeTargetEvents], + expectedTargetFront: iframeTarget, + }); + } else { + assertEvents({ + commands, + documentEvents: [null /* no will-navigate */, ...documentEvents], + }); + } + + info("Navigate the iframe to another process (if fission is enabled)"); + documentEvents = []; + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [secondPageUrl], + function (url) { + const iframe = content.document.querySelector("iframe"); + iframe.src = url; + } + ); + + // We are switching to a new target only when fission is enabled... + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + await waitFor(() => documentEvents.length >= 3); + is( + documentEvents.length, + 3, + "With fission/EFT, we switch to a new target and get: dom-loading, dom-interactive, dom-complete (but no will-navigate as that's only for the top BrowsingContext)" + ); + const [, newIframeTarget] = await commands.targetCommand.getAllTargets([ + commands.targetCommand.TYPES.FRAME, + ]); + assertEvents({ + commands, + targetBeforeNavigation: iframeTarget, + documentEvents: [null /* no will-navigate */, ...documentEvents], + expectedTargetFront: newIframeTarget, + expectedNewURI: secondPageUrl, + }); + } else { + // Wait for some time in order to let a chance to receive some unexpected events + await wait(250); + is( + documentEvents.length, + 0, + "If fission is disabled, we navigate within the same process, we get no new target and no new resource" + ); + } + + await commands.destroy(); +} + +function isBfCacheInParentEnabled() { + return ( + Services.appinfo.sessionHistoryInParent && + Services.prefs.getBoolPref("fission.bfcacheInParent", false) + ); +} + +async function testBfCacheNavigation() { + info("Test bfcache navigations for DOCUMENT_EVENT"); + + info("Open a first document and navigate to a second one"); + const firstLocation = "data:text/html,<title>first</title>first page"; + const secondLocation = "data:text/html,<title>second</title>second page"; + const tab = await addTab(firstLocation); + const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + secondLocation + ); + await onLoaded; + + const { commands } = await initResourceCommand(tab); + + const documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => { + documentEvents.push(...resources); + }, + ignoreExistingResources: true, + } + ); + // Wait for some time for extra safety + await wait(250); + is(documentEvents.length, 0, "Existing document events are not fired"); + + info("Navigate back to the first page"); + const onSwitched = commands.targetCommand.once("switched-target"); + const targetBeforeNavigation = commands.targetCommand.targetFront; + gBrowser.goBack(); + + // We are switching to a new target only when fission/EFT is enabled... + if ( + (isFissionEnabled() || isEveryFrameTargetEnabled()) && + isBfCacheInParentEnabled() + ) { + await onSwitched; + } + + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length >= 4); + /* Ignore will-navigate timestamp as all other DOCUMENT_EVENTS will be set at the original load date, + which is when we loaded from the network, and not when we loaded from bfcache */ + assertEvents({ + commands, + targetBeforeNavigation, + documentEvents, + ignoreWillNavigateTimestamp: true, + }); + + // Wait for some time in order to let a chance to have duplicated dom-loading events + await wait(250); + + is( + documentEvents.length, + 4, + "There is no duplicated event and only the 4 expected DOCUMENT_EVENT states" + ); + const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] = + documentEvents; + + is( + willNavigateEvent.name, + "will-navigate", + "The first DOCUMENT_EVENT is will-navigate" + ); + is( + loadingEvent.name, + "dom-loading", + "The second DOCUMENT_EVENT is dom-loading" + ); + is( + interactiveEvent.name, + "dom-interactive", + "The third DOCUMENT_EVENT is dom-interactive" + ); + is( + completeEvent.name, + "dom-complete", + "The fourth DOCUMENT_EVENT is dom-complete" + ); + + is( + loadingEvent.url, + firstLocation, + `resource ${loadingEvent.name} has expected url after navigation back` + ); + is( + loadingEvent.title, + undefined, + `resource ${loadingEvent.name} does not have a title property after navigating back` + ); + + is( + interactiveEvent.url, + firstLocation, + `resource ${interactiveEvent.name} has expected url property after navigating back` + ); + is( + interactiveEvent.title, + "first", + `resource ${interactiveEvent.name} has expected title after navigating back` + ); + + is( + completeEvent.url, + undefined, + `resource ${completeEvent.name} does not have a url property after navigating back` + ); + is( + completeEvent.title, + undefined, + `resource ${completeEvent.name} does not have a title property after navigating back` + ); + + await commands.destroy(); +} + +async function testCrossOriginNavigation() { + info("Test cross origin navigations for DOCUMENT_EVENT"); + + const tab = await addTab("https://example.com/document-builder.sjs?html=com"); + + const { commands } = await initResourceCommand(tab); + + const documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => documentEvents.push(...resources), + ignoreExistingResources: true, + } + ); + // Wait for some time for extra safety + await wait(250); + is(documentEvents.length, 0, "Existing document events are not fired"); + + info("Navigate to another process"); + const onSwitched = commands.targetCommand.once("switched-target"); + const netUrl = + "https://example.net/document-builder.sjs?html=<head><title>titleNet</title></head>net"; + const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + const targetBeforeNavigation = commands.targetCommand.targetFront; + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, netUrl); + await onLoaded; + + // We are switching to a new target only when fission is enabled... + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + await onSwitched; + } + + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length >= 4); + assertEvents({ commands, targetBeforeNavigation, documentEvents }); + + // Wait for some time in order to let a chance to have duplicated dom-loading events + await wait(250); + + is( + documentEvents.length, + 4, + "There is no duplicated event and only the 4 expected DOCUMENT_EVENT states" + ); + const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] = + documentEvents; + + is( + willNavigateEvent.name, + "will-navigate", + "The first DOCUMENT_EVENT is will-navigate" + ); + is( + loadingEvent.name, + "dom-loading", + "The second DOCUMENT_EVENT is dom-loading" + ); + is( + interactiveEvent.name, + "dom-interactive", + "The third DOCUMENT_EVENT is dom-interactive" + ); + is( + completeEvent.name, + "dom-complete", + "The fourth DOCUMENT_EVENT is dom-complete" + ); + + is( + loadingEvent.url, + encodeURI(netUrl), + `resource ${loadingEvent.name} has expected url after reloading` + ); + is( + loadingEvent.title, + undefined, + `resource ${loadingEvent.name} does not have a title property after reloading` + ); + + is( + interactiveEvent.url, + encodeURI(netUrl), + `resource ${interactiveEvent.name} has expected url property after reloading` + ); + is( + interactiveEvent.title, + "titleNet", + `resource ${interactiveEvent.name} has expected title after reloading` + ); + + is( + completeEvent.url, + undefined, + `resource ${completeEvent.name} does not have a url property after reloading` + ); + is( + completeEvent.title, + undefined, + `resource ${completeEvent.name} does not have a title property after reloading` + ); + + await commands.destroy(); +} + +async function testDomCompleteWithOverloadedConsole() { + info("Test dom-complete with an overloaded console object"); + + const tab = await addTab( + "data:text/html,<script>window.console = {};</script>" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check that all DOCUMENT_EVENTS are fired for the already loaded page"); + const documentEvents = []; + await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { + onAvailable: resources => documentEvents.push(...resources), + }); + is(documentEvents.length, 3, "Existing document events are fired"); + + const domComplete = documentEvents[2]; + is(domComplete.name, "dom-complete", "the last resource is the dom-complete"); + is( + domComplete.hasNativeConsoleAPI, + false, + "the console object is reported to be overloaded" + ); + + targetCommand.destroy(); + await client.close(); +} + +async function testDomCompleteWithWindowStop() { + info("Test dom-complete with a page calling window.stop()"); + + const tab = await addTab("data:text/html,foo"); + + const { commands, client, resourceCommand, targetCommand } = + await initResourceCommand(tab); + + info("Check that all DOCUMENT_EVENTS are fired for the already loaded page"); + let documentEvents = []; + await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { + onAvailable: resources => documentEvents.push(...resources), + }); + is(documentEvents.length, 3, "Existing document events are fired"); + documentEvents = []; + + const html = `<!DOCTYPE html><html> + <head> + <title>stopped page</title> + <script>window.stop();</script> + </head> + <body>Page content that shouldn't be displayed</body> +</html>`; + const secondLocation = "data:text/html," + encodeURIComponent(html); + const targetBeforeNavigation = commands.targetCommand.targetFront; + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + secondLocation + ); + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length === 4); + + assertEvents({ commands, targetBeforeNavigation, documentEvents }); + + targetCommand.destroy(); + await client.close(); +} + +async function assertPromises( + commands, + targetBeforeNavigation, + onWillNavigate, + onLoading, + onInteractive, + onComplete +) { + const willNavigateEvent = await onWillNavigate; + const loadingEvent = await onLoading; + const interactiveEvent = await onInteractive; + const completeEvent = await onComplete; + assertEvents({ + commands, + targetBeforeNavigation, + documentEvents: [ + willNavigateEvent, + loadingEvent, + interactiveEvent, + completeEvent, + ], + }); +} + +function assertEvents({ + commands, + targetBeforeNavigation, + documentEvents, + expectedTargetFront = commands.targetCommand.targetFront, + expectedNewURI = gBrowser.selectedBrowser.currentURI.spec, + ignoreWillNavigateTimestamp = false, +}) { + const [willNavigateEvent, loadingEvent, interactiveEvent, completeEvent] = + documentEvents; + if (willNavigateEvent) { + is(willNavigateEvent.name, "will-navigate", "Received the will-navigate"); + is( + willNavigateEvent.newURI, + expectedNewURI, + "will-navigate newURI is set to the current tab new location" + ); + } + is( + loadingEvent.name, + "dom-loading", + "loading received in the exepected order" + ); + is( + interactiveEvent.name, + "dom-interactive", + "interactive received in the expected order" + ); + is(completeEvent.name, "dom-complete", "complete received last"); + + if (willNavigateEvent) { + is( + typeof willNavigateEvent.time, + "number", + `Type of time attribute for will-navigate event is correct (${willNavigateEvent.time})` + ); + } + is( + typeof loadingEvent.time, + "number", + `Type of time attribute for loading event is correct (${loadingEvent.time})` + ); + is( + typeof interactiveEvent.time, + "number", + `Type of time attribute for interactive event is correct (${interactiveEvent.time})` + ); + is( + typeof completeEvent.time, + "number", + `Type of time attribute for complete event is correct (${completeEvent.time})` + ); + + if (willNavigateEvent && !ignoreWillNavigateTimestamp) { + Assert.lessOrEqual( + willNavigateEvent.time, + loadingEvent.time, + `Timestamp for dom-loading event is greater than will-navigate event (${willNavigateEvent.time} <= ${loadingEvent.time})` + ); + } + Assert.lessOrEqual( + loadingEvent.time, + interactiveEvent.time, + `Timestamp for interactive event is greater than loading event (${loadingEvent.time} <= ${interactiveEvent.time})` + ); + Assert.lessOrEqual( + interactiveEvent.time, + completeEvent.time, + `Timestamp for complete event is greater than interactive event (${interactiveEvent.time} <= ${completeEvent.time}).` + ); + + if (willNavigateEvent) { + // If we switched to a new target, this target will be different from currentTargetFront. + // This only happen if we navigate to another process or if server target switching is enabled. + is( + willNavigateEvent.targetFront, + targetBeforeNavigation, + "will-navigate target was the one before the navigation" + ); + } + is( + loadingEvent.targetFront, + expectedTargetFront, + "loading target is the expected one" + ); + is( + interactiveEvent.targetFront, + expectedTargetFront, + "interactive target is the expected one" + ); + is( + completeEvent.targetFront, + expectedTargetFront, + "complete target is the expected one" + ); + + is( + completeEvent.hasNativeConsoleAPI, + true, + "None of the tests (except the dedicated one) overload the console object" + ); +} + +class ResourceListener { + _listeners = new Map(); + + dispatch(resources) { + for (const resource of resources) { + const resolve = this._listeners.get(resource.name); + if (resolve) { + resolve(resource); + this._listeners.delete(resource.name); + } + } + } + + once(resourceName) { + return new Promise(r => this._listeners.set(resourceName, r)); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_error_messages.js b/devtools/shared/commands/resource/tests/browser_resources_error_messages.js new file mode 100644 index 0000000000..6f94266e4c --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_error_messages.js @@ -0,0 +1,877 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around ERROR_MESSAGE +// Reproduces assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html + +// 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(`<!DOCTYPE html><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, resourceCommand, targetCommand } = await initResourceCommand( + 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 ResourceCommand.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, + targetCommand.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(resource); + + const isAlreadyExistingResource = + receivedMessages.length <= expectedPageErrors.size; + is( + resource.isAlreadyExistingResource, + isAlreadyExistingResource, + "isAlreadyExistingResource has expected value" + ); + + 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 resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + await BrowserTestUtils.waitForCondition( + () => receivedMessages.length === expectedPageErrors.size + ); + + info( + "Now log errors *after* the call to ResourceCommand.watchResources and after having" + + " received all existing messages" + ); + 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(); + targetCommand.destroy(); + await client.close(); +} + +async function testErrorMessagesResourcesWithIgnoreExistingResources() { + info("Test ignoreExistingResources option for ERROR_MESSAGE"); + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info( + "Check whether onAvailable will not be called with existing error messages" + ); + await triggerErrors(tab); + + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.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 resource = availableResources[i]; + const { pageError } = resource; + const expected = expectedMessages[i]; + checkPageErrorResource(pageError, expected); + is( + resource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is set to false for live messages" + ); + } + + Services.console.reset(); + targetCommand.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+$/; +// timeStamp are the result of a number in microsecond divided by 1000. +// so we can't expect a precise number of decimals, or even if there would +// be decimals at all. +const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/; + +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: "JSMSG_NOT_FUNCTION", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_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: FRACTIONAL_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: FRACTIONAL_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: FRACTIONAL_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: FRACTIONAL_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: FRACTIONAL_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: FRACTIONAL_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: FRACTIONAL_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: FRACTIONAL_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: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: undefined, + 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: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: undefined, + 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: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + exceptionDocURL: undefined, + 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, + }, + ], + [ + // Error with a cause + `var originalError = new TypeError("abc"); + var error = new Error("something went wrong", { cause: originalError }) + throw error`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 2, + columnNumber: 19, + functionName: null, + }, + ], + exception: { + preview: { + cause: { + class: "TypeError", + preview: { + message: "abc", + }, + }, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with a cause chain + `var a = new Error("err-a"); + var b = new Error("err-b", { cause: a }); + var c = new Error("err-c", { cause: b }); + var d = new Error("err-d", { cause: c }); + throw d`, + { + errorMessage: /Error: err-d/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 4, + columnNumber: 14, + functionName: null, + }, + ], + exception: { + preview: { + cause: { + class: "Error", + preview: { + message: "err-c", + cause: { + class: "Error", + preview: { + message: "err-b", + cause: { + class: "Error", + preview: { + message: "err-a", + }, + }, + }, + }, + }, + }, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with a null cause + `throw new Error("something went wrong", { cause: null })`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + exception: { + preview: { + cause: { + type: "null", + }, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with an undefined cause + `throw new Error("something went wrong", { cause: undefined })`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + exception: { + preview: { + cause: { + type: "undefined", + }, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with a number cause + `throw new Error("something went wrong", { cause: 0 })`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + exception: { + preview: { + cause: 0, + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], + [ + // Error with a string cause + `throw new Error("something went wrong", { cause: "ooops" })`, + { + errorMessage: /Error: something went wrong/, + errorMessageName: "", + sourceName: /test_page_errors/, + category: "content javascript", + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: true, + warning: false, + info: false, + lineText: "", + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + private: false, + stacktrace: [ + { + filename: /test_page_errors\.html/, + lineNumber: 1, + columnNumber: 7, + functionName: null, + }, + ], + exception: { + preview: { + cause: "ooops", + }, + }, + notes: null, + chromeContext: false, + isPromiseRejection: false, + isForwardedFromContentProcess: false, + }, + ], +]); diff --git a/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js b/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js new file mode 100644 index 0000000000..10bc8390d9 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test getAllResources function of the ResourceCommand. + +const TEST_URI = "data:text/html;charset=utf-8,getAllResources test"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check the resources gotten from getAllResources at initial"); + is( + resourceCommand.getAllResources(resourceCommand.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 resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + + info("Check the resources after some resources are available"); + const messages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, messages); + + try { + await waitFor(() => availableResources.length === messages.length); + } catch (e) { + ok( + false, + `Didn't receive the expected number of resources. Got ${ + availableResources.length + }, expected ${messages.length} - ${availableResources + .map(r => r.message.arguments[0]) + .join(" - ")}` + ); + } + + assertResources( + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE), + availableResources + ); + assertResources( + resourceCommand.getAllResources(resourceCommand.TYPES.STYLESHEET), + [] + ); + + info("Check the resources after reloading"); + await BrowserTestUtils.reloadTab(tab); + assertResources( + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE), + [] + ); + + info("Append some resources again to test unwatching"); + const newMessages = ["d", "e", "f"]; + await logMessages(tab.linkedBrowser, messages); + try { + await waitFor( + () => + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE) + .length === newMessages.length + ); + } catch (e) { + const resources = resourceCommand.getAllResources( + resourceCommand.TYPES.CONSOLE_MESSAGE + ); + ok( + false, + `Didn't receive the expected number of resources. Got ${ + resources.length + }, expected ${messages.length} - ${resources + .map(r => r.message.arguments.join(" | ")) + .join(" - ")}` + ); + } + + info("Check the resources after unwatching"); + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); + assertResources( + resourceCommand.getAllResources(resourceCommand.TYPES.CONSOLE_MESSAGE), + [] + ); + + targetCommand.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]; + Assert.strictEqual( + resource, + expectedResource, + `The ${i}th resource is correct` + ); + } +} + +function logMessages(browser, messages) { + return SpecialPowers.spawn(browser, [messages], innerMessages => { + for (const message of innerMessages) { + content.console.log(message); + } + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js b/devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js new file mode 100644 index 0000000000..8a1d809f04 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test watch/unwatchResources throw when provided with invalid types. + +const TEST_URI = "data:text/html;charset=utf-8,invalid api usage test"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const onAvailable = function () {}; + + await Assert.rejects( + resourceCommand.watchResources([null], { onAvailable }), + /ResourceCommand\.watchResources invoked with an unknown type/, + "watchResources should throw for null type" + ); + + await Assert.rejects( + resourceCommand.watchResources([undefined], { onAvailable }), + /ResourceCommand\.watchResources invoked with an unknown type/, + "watchResources should throw for undefined type" + ); + + await Assert.rejects( + resourceCommand.watchResources(["NOT_A_RESOURCE"], { onAvailable }), + /ResourceCommand\.watchResources invoked with an unknown type/, + "watchResources should throw for unknown type" + ); + + await Assert.rejects( + resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE, "NOT_A_RESOURCE"], + { onAvailable } + ), + /ResourceCommand\.watchResources invoked with an unknown type/, + "watchResources should throw for unknown type mixed with a correct type" + ); + + await Assert.throws( + () => resourceCommand.unwatchResources([null], { onAvailable }), + /ResourceCommand\.unwatchResources invoked with an unknown type/, + "unwatchResources should throw for null type" + ); + + await Assert.throws( + () => resourceCommand.unwatchResources([undefined], { onAvailable }), + /ResourceCommand\.unwatchResources invoked with an unknown type/, + "unwatchResources should throw for undefined type" + ); + + await Assert.throws( + () => resourceCommand.unwatchResources(["NOT_A_RESOURCE"], { onAvailable }), + /ResourceCommand\.unwatchResources invoked with an unknown type/, + "unwatchResources should throw for unknown type" + ); + + await Assert.throws( + () => + resourceCommand.unwatchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE, "NOT_A_RESOURCE"], + { onAvailable } + ), + /ResourceCommand\.unwatchResources invoked with an unknown type/, + "unwatchResources should throw for unknown type mixed with a correct type" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js b/devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js new file mode 100644 index 0000000000..1e2d894be3 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Verify that LAST_PRIVATE_CONTEXT_EXIT fires when closing the last opened private window + +"use strict"; + +const NON_PRIVATE_TEST_URI = + "data:text/html;charset=utf8,<!DOCTYPE html>Not private"; +const PRIVATE_TEST_URI = `data:text/html;charset=utf8,<!DOCTYPE html>Test in private windows`; + +add_task(async function () { + await pushPref("devtools.browsertoolbox.scope", "everything"); + const { commands } = await initMultiProcessResourceCommand(); + const { resourceCommand } = commands; + + const availableResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.LAST_PRIVATE_CONTEXT_EXIT], + { + onAvailable(resources) { + availableResources.push(resources); + }, + } + ); + is( + availableResources.length, + 0, + "We do not get any LAST_PRIVATE_CONTEXT_EXIT after initialization" + ); + + await addTab(NON_PRIVATE_TEST_URI); + + info("Open a new private window and select the new tab opened in it"); + const privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + ok(PrivateBrowsingUtils.isWindowPrivate(privateWindow), "window is private"); + const privateBrowser = privateWindow.gBrowser; + privateBrowser.selectedTab = BrowserTestUtils.addTab( + privateBrowser, + PRIVATE_TEST_URI + ); + + info("private tab opened"); + ok( + PrivateBrowsingUtils.isBrowserPrivate(privateBrowser.selectedBrowser), + "tab window is private" + ); + + info("Open a second tab in the private window"); + await addTab(PRIVATE_TEST_URI, { window: privateWindow }); + + // Let a chance to an unexpected async event to be fired + await wait(1000); + + is( + availableResources.length, + 0, + "We do not get any LAST_PRIVATE_CONTEXT_EXIT when opening a private window" + ); + + info("Open a second private browsing window"); + const secondPrivateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Close the second private window"); + secondPrivateWindow.BrowserTryToCloseWindow(); + + // Let a chance to an unexpected async event to be fired + await wait(1000); + + is( + availableResources.length, + 0, + "We do not get any LAST_PRIVATE_CONTEXT_EXIT when closing the second private window only" + ); + + info( + "close the private window and check if LAST_PRIVATE_CONTEXT_EXIT resource is sent" + ); + privateWindow.BrowserTryToCloseWindow(); + + info("Wait for LAST_PRIVATE_CONTEXT_EXIT"); + await waitFor(() => availableResources.length == 1); + is( + availableResources.length, + 1, + "We get one LAST_PRIVATE_CONTEXT_EXIT when closing the last opened private window" + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js b/devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js new file mode 100644 index 0000000000..2200fcad9c --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js @@ -0,0 +1,100 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around NETWORK_EVENT_STACKTRACE + +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/commands/resource/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, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const networkEvents = new Map(); + const stackTraces = new Map(); + + function onResourceAvailable(resources) { + for (const resource of resources) { + if ( + resource.resourceType === resourceCommand.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 === resourceCommand.TYPES.NETWORK_EVENT) { + ok( + stackTraces.has(resource.stacktraceResourceId), + "The stack trace does exists" + ); + + networkEvents.set(resource.resourceId, true); + } + } + } + + function onResourceUpdated() {} + + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + } + ); + + await triggerNetworkRequests(tab.linkedBrowser, [REQUEST_STUB.code]); + + resourceCommand.unwatchResources( + [ + resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { + onAvailable: onResourceAvailable, + onUpdated: onResourceUpdated, + } + ); + + targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events.js b/devtools/shared/commands/resource/tests/browser_resources_network_events.js new file mode 100644 index 0000000000..da355fd023 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events.js @@ -0,0 +1,318 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around NETWORK_EVENT + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +// We are borrowing tests from the netmonitor frontend +const NETMONITOR_TEST_FOLDER = + "https://example.com/browser/devtools/client/netmonitor/test/"; +const CSP_URL = `${NETMONITOR_TEST_FOLDER}html_csp-test-page.html`; +const JS_CSP_URL = `${NETMONITOR_TEST_FOLDER}js_websocket-worker-test.js`; +const CSS_CSP_URL = `${NETMONITOR_TEST_FOLDER}internal-loaded.css`; + +const CSP_BLOCKED_REASON_CODE = 4000; + +add_task(async function testContentProcessRequests() { + info(`Tests for NETWORK_EVENT resources fired from the content process`); + + const expectedAvailable = [ + { + url: CSP_URL, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: JS_CSP_URL, + method: "GET", + blockedReason: CSP_BLOCKED_REASON_CODE, + isNavigationRequest: false, + chromeContext: false, + }, + { + url: CSS_CSP_URL, + method: "GET", + blockedReason: CSP_BLOCKED_REASON_CODE, + isNavigationRequest: false, + chromeContext: false, + }, + ]; + const expectedUpdated = [ + { + url: CSP_URL, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: JS_CSP_URL, + method: "GET", + blockedReason: CSP_BLOCKED_REASON_CODE, + isNavigationRequest: false, + chromeContext: false, + }, + { + url: CSS_CSP_URL, + method: "GET", + blockedReason: CSP_BLOCKED_REASON_CODE, + isNavigationRequest: false, + chromeContext: false, + }, + ]; + + await assertNetworkResourcesOnPage( + CSP_URL, + expectedAvailable, + expectedUpdated + ); +}); + +add_task(async function testCanceledRequest() { + info(`Tests for NETWORK_EVENT resources with a canceled request`); + + // Do a XHR request that we cancel against a slow loading page + const requestUrl = + "https://example.org/document-builder.sjs?delay=1000&html=foo"; + const html = + "<!DOCTYPE html><script>(" + + function (xhrUrl) { + const xhr = new XMLHttpRequest(); + xhr.open("GET", xhrUrl); + xhr.send(null); + } + + ")(" + + JSON.stringify(requestUrl) + + ")</script>"; + const pageUrl = + "https://example.org/document-builder.sjs?html=" + encodeURIComponent(html); + + const expectedAvailable = [ + { + url: pageUrl, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: requestUrl, + method: "GET", + isNavigationRequest: false, + blockedReason: "NS_BINDING_ABORTED", + chromeContext: false, + }, + ]; + const expectedUpdated = [ + { + url: pageUrl, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: requestUrl, + method: "GET", + isNavigationRequest: false, + blockedReason: "NS_BINDING_ABORTED", + chromeContext: false, + }, + ]; + + // Register a one-off listener to cancel the XHR request + // Using XMLHttpRequest's abort() method from the content process + // isn't reliable and would introduce many race condition in the test. + // Canceling the request via nsIRequest.cancel privileged method, + // from the parent process is much more reliable. + const observer = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + observe(subject, topic, data) { + subject = subject.QueryInterface(Ci.nsIHttpChannel); + if (subject.URI.spec == requestUrl) { + subject.cancel(Cr.NS_BINDING_ABORTED); + Services.obs.removeObserver(observer, "http-on-modify-request"); + } + }, + }; + Services.obs.addObserver(observer, "http-on-modify-request"); + + await assertNetworkResourcesOnPage( + pageUrl, + expectedAvailable, + expectedUpdated + ); +}); + +add_task(async function testIframeRequest() { + info(`Tests for NETWORK_EVENT resources with an iframe`); + + // Do a XHR request that we cancel against a slow loading page + const iframeRequestUrl = + "https://example.org/document-builder.sjs?html=iframe-request"; + const iframeHtml = `iframe<script>fetch("${iframeRequestUrl}")</script>`; + const iframeUrl = + "https://example.org/document-builder.sjs?html=" + + encodeURIComponent(iframeHtml); + const html = `top-document<iframe src="${iframeUrl}"></iframe>`; + const pageUrl = + "https://example.org/document-builder.sjs?html=" + encodeURIComponent(html); + + const expectedAvailable = [ + { + url: pageUrl, + method: "GET", + chromeContext: false, + isNavigationRequest: true, + // The top level navigation request relates to the previous top level target. + // Unfortunately, it is hard to test because it is racy. + // The target front might be destroyed and `targetFront.url` will be null. + // Or not just yet and be equal to "about:blank". + }, + { + url: iframeUrl, + method: "GET", + isNavigationRequest: false, + targetFrontUrl: pageUrl, + chromeContext: false, + }, + { + url: iframeRequestUrl, + method: "GET", + isNavigationRequest: false, + targetFrontUrl: iframeUrl, + chromeContext: false, + }, + ]; + const expectedUpdated = [ + { + url: pageUrl, + method: "GET", + isNavigationRequest: true, + chromeContext: false, + }, + { + url: iframeUrl, + method: "GET", + isNavigationRequest: false, + chromeContext: false, + }, + { + url: iframeRequestUrl, + method: "GET", + isNavigationRequest: false, + chromeContext: false, + }, + ]; + + await assertNetworkResourcesOnPage( + pageUrl, + expectedAvailable, + expectedUpdated + ); +}); + +async function assertNetworkResourcesOnPage( + url, + expectedAvailable, + expectedUpdated +) { + // First open a blank document to avoid spawning any request + const tab = await addTab("about:blank"); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + const { resourceCommand } = commands; + + const onAvailable = resources => { + for (const resource of resources) { + // Immediately assert the resource, as the same resource object + // will be notified to onUpdated and so if we assert it later + // we will not highlight attributes that aren't set yet from onAvailable. + const idx = expectedAvailable.findIndex(e => e.url === resource.url); + Assert.notEqual( + idx, + -1, + "Found a matching available notification for: " + resource.url + ); + // Remove the match from the list in case there is many requests with the same url + const [expected] = expectedAvailable.splice(idx, 1); + + assertResources(resource, expected); + } + }; + const onUpdated = updates => { + for (const { resource } of updates) { + const idx = expectedUpdated.findIndex(e => e.url === resource.url); + Assert.notEqual( + idx, + -1, + "Found a matching updated notification for: " + resource.url + ); + // Remove the match from the list in case there is many requests with the same url + const [expected] = expectedUpdated.splice(idx, 1); + + assertResources(resource, expected); + } + }; + + // Start observing for network events before loading the test page + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + }); + + // Load the test page that fires network requests + const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString(gBrowser.selectedBrowser, url); + await onLoaded; + + // Make sure we processed all the expected request updates + await waitFor( + () => !expectedAvailable.length, + "Wait for all expected available notifications" + ); + await waitFor( + () => !expectedUpdated.length, + "Wait for all expected updated notifications" + ); + + resourceCommand.unwatchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + }); + + await commands.destroy(); + + BrowserTestUtils.removeTab(tab); +} + +function assertResources(actual, expected) { + is( + actual.resourceType, + ResourceCommand.TYPES.NETWORK_EVENT, + "The resource type is correct" + ); + is( + typeof actual.innerWindowId, + "number", + "All requests have an innerWindowId attribute" + ); + ok( + actual.targetFront.isTargetFront, + "All requests have a targetFront attribute" + ); + + for (const name in expected) { + if (name == "targetFrontUrl") { + is( + actual.targetFront.url, + expected[name], + "The request matches the right target front" + ); + } else { + is(actual[name], expected[name], `The '${name}' attribute is correct`); + } + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js b/devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js new file mode 100644 index 0000000000..6708ef19e1 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js @@ -0,0 +1,236 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API internal cache / ignoreExistingResources around NETWORK_EVENT + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const EXAMPLE_DOMAIN = "https://example.com/"; +const TEST_URI = `${URL_ROOT_SSL}network_document.html`; + +add_task(async function () { + info("Test basic NETWORK_EVENT resources against ResourceCommand cache"); + await testNetworkEventResourcesWithExistingResources(); + await testNetworkEventResourcesWithoutExistingResources(); +}); + +async function testNetworkEventResourcesWithExistingResources() { + info(`Tests for network event resources with the existing resources`); + await testNetworkEventResourcesWithCachedRequest({ + ignoreExistingResources: false, + // 1 available event fired, for the existing resource in the cache. + // 1 available event fired, when live request is created. + expectedResourcesOnAvailable: { + [`${EXAMPLE_DOMAIN}cached_post.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "POST", + isNavigationRequest: false, + }, + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "GET", + isNavigationRequest: false, + }, + }, + // 1 update events fired, when live request is updated. + expectedResourcesOnUpdated: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + }); +} + +async function testNetworkEventResourcesWithoutExistingResources() { + info(`Tests for network event resources without the existing resources`); + await testNetworkEventResourcesWithCachedRequest({ + ignoreExistingResources: true, + // 1 available event fired, when live request is created. + expectedResourcesOnAvailable: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "GET", + isNavigationRequest: false, + }, + }, + // 1 update events fired, when live request is updated. + expectedResourcesOnUpdated: { + [`${EXAMPLE_DOMAIN}live_get.html`]: { + resourceType: ResourceCommand.TYPES.NETWORK_EVENT, + method: "GET", + }, + }, + }); +} + +/** + * This test helper is slightly complex as we workaround the fact + * that the server is not able to record network request done in the past. + * Because of that we have to start observer requests via ResourceCommand.watchResources + * before doing a request, and, before doing the actual call to watchResources + * we want to assert the behavior of. + */ +async function testNetworkEventResourcesWithCachedRequest(options) { + const tab = await addTab(TEST_URI); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const { resourceCommand } = commands; + + info( + `Trigger some network requests *before* calling ResourceCommand.watchResources + in order to assert the behavior of already existing network events.` + ); + + // Register a first empty listener in order to ensure populating ResourceCommand + // internal cache of NETWORK_EVENT's. We can't retrieved past network requests + // when calling server's `watchResources`. + let resolveCachedRequestAvailable; + const onCachedRequestAvailable = new Promise( + r => (resolveCachedRequestAvailable = r) + ); + const onAvailableToPopulateInternalCache = () => {}; + const onUpdatedToPopulateInternalCache = resolveCachedRequestAvailable; + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + ignoreExistingResources: true, + onAvailable: onAvailableToPopulateInternalCache, + onUpdated: onUpdatedToPopulateInternalCache, + }); + + // We can only trigger the requests once `watchResources` settles, + // otherwise we might miss some events and they won't be present in the cache + const cachedRequest = `await fetch("/cached_post.html", { method: "POST" });`; + await triggerNetworkRequests(tab.linkedBrowser, [cachedRequest]); + + // We have to ensure that ResourceCommand processed the Resource for this first + // cached request before calling watchResource a second time and report it. + // Wait for the updated notification to avoid receiving it during the next call + // to watchResources. + await onCachedRequestAvailable; + + const actualResourcesOnAvailable = {}; + const actualResourcesOnUpdated = {}; + + const { + expectedResourcesOnAvailable, + expectedResourcesOnUpdated, + + ignoreExistingResources, + } = options; + + const onAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network event resource" + ); + actualResourcesOnAvailable[resource.url] = resource; + } + }; + + const onUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + actualResourcesOnUpdated[resource.url] = resource; + } + }; + + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + ignoreExistingResources, + }); + + info( + `Trigger the rest of the requests *after* calling ResourceCommand.watchResources + in order to assert the behavior of live network events.` + ); + const liveRequest = `await fetch("/live_get.html", { method: "GET" });`; + await triggerNetworkRequests(tab.linkedBrowser, [liveRequest]); + + info("Check the resources on available"); + + await waitUntil( + () => + Object.keys(actualResourcesOnAvailable).length == + Object.keys(expectedResourcesOnAvailable).length + ); + + is( + Object.keys(actualResourcesOnAvailable).length, + Object.keys(expectedResourcesOnAvailable).length, + "Got the expected number of network events fired onAvailable" + ); + + // 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"); + + await waitUntil( + () => + Object.keys(actualResourcesOnUpdated).length == + Object.keys(expectedResourcesOnUpdated).length + ); + + 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); + // assert that the resourceId for the the available and updated events match + is( + actual.resourceId, + actualResourcesOnAvailable[key].resourceId, + `Available and update resource ids for ${key} are the same` + ); + } + + resourceCommand.unwatchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable, + onUpdated, + ignoreExistingResources, + }); + + resourceCommand.unwatchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable: onAvailableToPopulateInternalCache, + }); + + await commands.destroy(); + + 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"); + if ("isNavigationRequest" in expected) { + is( + actual.isNavigationRequest, + expected.isNavigationRequest, + "The isNavigationRequest attribute is correct" + ); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js new file mode 100644 index 0000000000..44028318a2 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around NETWORK_EVENT when navigating + +const TEST_URI = `${URL_ROOT_SSL}network_document_navigation.html`; +const JS_URI = TEST_URI.replace( + "network_document_navigation.html", + "network_navigation.js" +); + +add_task(async () => { + const tab = await addTab(TEST_URI); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + const { resourceCommand } = commands; + + const receivedResources = []; + const onAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network event resource" + ); + receivedResources.push(resource); + } + }; + const onUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + } + }; + + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + ignoreExistingResources: true, + onAvailable, + onUpdated, + }); + + await reloadBrowser(); + + await waitFor(() => receivedResources.length == 2); + + const navigationRequest = receivedResources[0]; + is( + navigationRequest.url, + TEST_URI, + "The first resource is for the navigation request" + ); + + const jsRequest = receivedResources[1]; + is(jsRequest.url, JS_URI, "The second resource is for the javascript file"); + + async function getResponseContent(networkEvent) { + const packet = { + to: networkEvent.actor, + type: "getResponseContent", + }; + const response = await commands.client.request(packet); + return response.content.text; + } + + const HTML_CONTENT = await (await fetch(TEST_URI)).text(); + const JS_CONTENT = await (await fetch(JS_URI)).text(); + + const htmlContent = await getResponseContent(navigationRequest); + is(htmlContent, HTML_CONTENT); + const jsContent = await getResponseContent(jsRequest); + is(jsContent, JS_CONTENT); + + await reloadBrowser(); + + await waitFor(() => receivedResources.length == 4); + + try { + await getResponseContent(navigationRequest); + ok(false, "Shouldn't work"); + } catch (e) { + is( + e.error, + "noSuchActor", + "Without persist, we can't fetch previous document network data" + ); + } + + try { + await getResponseContent(jsRequest); + ok(false, "Shouldn't work"); + } catch (e) { + is( + e.error, + "noSuchActor", + "Without persist, we can't fetch previous document network data" + ); + } + + const navigationRequest2 = receivedResources[2]; + const jsRequest2 = receivedResources[3]; + info("But we can fetch data for the last/new document"); + const htmlContent2 = await getResponseContent(navigationRequest2); + is(htmlContent2, HTML_CONTENT); + const jsContent2 = await getResponseContent(jsRequest2); + is(jsContent2, JS_CONTENT); + + info("Enable persist"); + const networkParentFront = + await commands.watcherFront.getNetworkParentActor(); + await networkParentFront.setPersist(true); + + await reloadBrowser(); + + await waitFor(() => receivedResources.length == 6); + + info("With persist, we can fetch previous document network data"); + const htmlContent3 = await getResponseContent(navigationRequest2); + is(htmlContent3, HTML_CONTENT); + const jsContent3 = await getResponseContent(jsRequest2); + is(jsContent3, JS_CONTENT); + + await resourceCommand.unwatchResources( + [resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + } + ); + + await commands.destroy(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js b/devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js new file mode 100644 index 0000000000..c5b3e436db --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js @@ -0,0 +1,249 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * !! AFTER MOVING OR RENAMING THIS METHOD, UPDATE `EXPECTED` CONSTANTS BELOW !! + */ +const createParentProcessRequests = async () => { + info("Do some requests from the parent process"); + // The line:column for `fetch` should be EXPECTED_REQUEST_LINE_1/COL_1 + await fetch(FETCH_URI); + + const img = new Image(); + const onLoad = new Promise(r => img.addEventListener("load", r)); + // The line:column for `img` below should be EXPECTED_REQUEST_LINE_2/COL_2 + img.src = IMAGE_URI; + await onLoad; +}; + +const EXPECTED_METHOD_NAME = "createParentProcessRequests"; +const EXPECTED_REQUEST_LINE_1 = 12; +const EXPECTED_REQUEST_COL_1 = 9; +const EXPECTED_REQUEST_LINE_2 = 17; +const EXPECTED_REQUEST_COL_2 = 3; + +// Test the ResourceCommand API around NETWORK_EVENT for the parent process + +const FETCH_URI = "https://example.com/document-builder.sjs?html=foo"; +// The img.src request gets cached regardless of `devtools.cache.disabled`. +// Add a random parameter to the request to bypass the cache. +const uuid = `${Date.now()}-${Math.random()}`; +const IMAGE_URI = URL_ROOT_SSL + "test_image.png?" + uuid; + +add_task(async function testParentProcessRequests() { + // The test expects the main process commands instance to receive resources + // for content process requests. + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const commands = await CommandsFactory.forMainProcess(); + await commands.targetCommand.startListening(); + const { resourceCommand } = commands; + + const receivedNetworkEvents = []; + const receivedStacktraces = []; + const onAvailable = resources => { + for (const resource of resources) { + if (resource.resourceType == resourceCommand.TYPES.NETWORK_EVENT) { + receivedNetworkEvents.push(resource); + } else if ( + resource.resourceType == resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE + ) { + receivedStacktraces.push(resource); + } + } + }; + const onUpdated = updates => { + for (const { resource } of updates) { + is( + resource.resourceType, + resourceCommand.TYPES.NETWORK_EVENT, + "Received a network update event resource" + ); + } + }; + + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.NETWORK_EVENT, + resourceCommand.TYPES.NETWORK_EVENT_STACKTRACE, + ], + { + ignoreExistingResources: true, + onAvailable, + onUpdated, + } + ); + + await createParentProcessRequests(); + + const img2 = new Image(); + img2.src = IMAGE_URI; + + info("Wait for the network events"); + await waitFor(() => receivedNetworkEvents.length == 3); + info("Wait for the network events stack traces"); + // Note that we aren't getting any stacktrace for the second cached request + await waitFor(() => receivedStacktraces.length == 2); + + info("Assert the fetch request"); + const fetchRequest = receivedNetworkEvents[0]; + is( + fetchRequest.url, + FETCH_URI, + "The first resource is for the fetch request" + ); + ok(fetchRequest.chromeContext, "The fetch request is privileged"); + + const fetchStacktrace = receivedStacktraces[0].lastFrame; + is(receivedStacktraces[0].resourceId, fetchRequest.stacktraceResourceId); + is(fetchStacktrace.filename, gTestPath); + is(fetchStacktrace.lineNumber, EXPECTED_REQUEST_LINE_1); + is(fetchStacktrace.columnNumber, EXPECTED_REQUEST_COL_1); + is(fetchStacktrace.functionName, EXPECTED_METHOD_NAME); + is(fetchStacktrace.asyncCause, null); + + async function getResponseContent(networkEvent) { + const packet = { + to: networkEvent.actor, + type: "getResponseContent", + }; + const response = await commands.client.request(packet); + return response.content.text; + } + + const fetchContent = await getResponseContent(fetchRequest); + is(fetchContent, "foo"); + + info("Assert the first image request"); + const firstImageRequest = receivedNetworkEvents[1]; + is( + firstImageRequest.url, + IMAGE_URI, + "The second resource is for the first image request" + ); + ok(!firstImageRequest.fromCache, "The first image request isn't cached"); + ok(firstImageRequest.chromeContext, "The first image request is privileged"); + + const firstImageStacktrace = receivedStacktraces[1].lastFrame; + is(receivedStacktraces[1].resourceId, firstImageRequest.stacktraceResourceId); + is(firstImageStacktrace.filename, gTestPath); + is(firstImageStacktrace.lineNumber, EXPECTED_REQUEST_LINE_2); + is(firstImageStacktrace.columnNumber, EXPECTED_REQUEST_COL_2); + is(firstImageStacktrace.functionName, EXPECTED_METHOD_NAME); + is(firstImageStacktrace.asyncCause, null); + + info("Assert the second image request"); + const secondImageRequest = receivedNetworkEvents[2]; + is( + secondImageRequest.url, + IMAGE_URI, + "The third resource is for the second image request" + ); + ok(secondImageRequest.fromCache, "The second image request is cached"); + ok( + secondImageRequest.chromeContext, + "The second image request is privileged" + ); + + info( + "Open a content page to ensure we also receive request from content processes" + ); + const pageUrl = "https://example.org/document-builder.sjs?html=foo"; + const requestUrl = "https://example.org/document-builder.sjs?html=bar"; + const tab = await addTab(pageUrl); + + await waitFor(() => receivedNetworkEvents.length == 4); + const tabRequest = receivedNetworkEvents[3]; + is(tabRequest.url, pageUrl, "The 4th resource is for the tab request"); + ok(!tabRequest.chromeContext, "The 4th request is content"); + + info( + "Also spawn a privileged request from the content process, not bound to any WindowGlobal" + ); + await SpecialPowers.spawn( + tab.linkedBrowser, + [requestUrl], + async function (uri) { + const { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" + ); + const channel = NetUtil.newChannel({ + uri, + loadUsingSystemPrincipal: true, + }); + channel.open(); + } + ); + await removeTab(tab); + + await waitFor(() => receivedNetworkEvents.length == 5); + const privilegedContentRequest = receivedNetworkEvents[4]; + is( + privilegedContentRequest.url, + requestUrl, + "The 5th resource is for the privileged content process request" + ); + ok(privilegedContentRequest.chromeContext, "The 5th request is privileged"); + + info("Now focus only on parent process resources"); + await pushPref("devtools.browsertoolbox.scope", "parent-process"); + + info( + "Retrigger the two last requests. The tab document request and a privileged request. Both happening in the tab's content process." + ); + const secondTab = await addTab(pageUrl); + await SpecialPowers.spawn( + secondTab.linkedBrowser, + [requestUrl], + async function (uri) { + const { NetUtil } = ChromeUtils.importESModule( + "resource://gre/modules/NetUtil.sys.mjs" + ); + const channel = NetUtil.newChannel({ + uri, + loadUsingSystemPrincipal: true, + }); + channel.open(); + } + ); + + await waitFor(() => receivedNetworkEvents.length == 6); + + // nsIHttpChannel doesn't expose any attribute allowing to identify + // privileged requests done in content processes. + // Thus, preventing us from filtering them out correctly. + // Ideally, we would need some new attribute to know from which (content) process + // any channel originates from. + info( + "For now, we are still notified about the privileged content process request" + ); + const secondPrivilegedContentRequest = receivedNetworkEvents[5]; + is( + secondPrivilegedContentRequest.url, + requestUrl, + "The 6th resource is for the second privileged content process request" + ); + ok(privilegedContentRequest.chromeContext, "The 6th request is privileged"); + + // Let some time to receive the tab request if that's not correctly filtered out + await wait(1000); + is( + receivedNetworkEvents.length, + 6, + "But we don't receive the request for the tab request" + ); + + await removeTab(secondTab); + + await resourceCommand.unwatchResources( + [resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + } + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_platform_messages.js b/devtools/shared/commands/resource/tests/browser_resources_platform_messages.js new file mode 100644 index 0000000000..4e74a97e38 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_platform_messages.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around PLATFORM_MESSAGE +// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_nsiconsolemessage.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 testPlatformMessagesResources(); + await testPlatformMessagesResourcesWithIgnoreExistingResources(); +}); + +async function testPlatformMessagesResources() { + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const cachedMessages = [ + "This is a cached message", + "This is another cached message", + ]; + const liveMessages = [ + "This is a live message", + "This is another live message", + ]; + const expectedMessages = [...cachedMessages, ...liveMessages]; + const receivedMessages = []; + + info( + "Log some messages *before* calling ResourceCommand.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, + targetCommand.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` + ); + + // timeStamp are the result of a number in microsecond divided by 1000. + // so we can't expect a precise number of decimals, or even if there would + // be decimals at all. + ok( + resource.timeStamp.toString().match(/^\d+(\.\d{1,3})?$/), + `The resource has a timeStamp property ${resource.timeStamp}` + ); + + const isCachedMessage = receivedMessages.length <= cachedMessages.length; + is( + resource.isAlreadyExistingResource, + isCachedMessage, + "isAlreadyExistingResource has the expected value" + ); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.PLATFORM_MESSAGE], + { + onAvailable, + } + ); + + info( + "Now log messages *after* the call to ResourceCommand.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(); + targetCommand.destroy(); + await client.close(); +} + +async function testPlatformMessagesResourcesWithIgnoreExistingResources() { + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + 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 resourceCommand.watchResources( + [resourceCommand.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 resource = availableResources[i]; + const { message } = resource; + const expected = expectedMessages[i]; + is(message, expected, `Message[${i}] is correct`); + is( + resource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false since we ignore existing resources" + ); + } + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_reflows.js b/devtools/shared/commands/resource/tests/browser_resources_reflows.js new file mode 100644 index 0000000000..70242c826a --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_reflows.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API for reflows + +const { + TYPES, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +add_task(async function () { + const tab = await addTab( + "https://example.com/document-builder.sjs?html=<h1>Test reflow resources</h1>" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const resources = []; + const onAvailable = _resources => { + resources.push(..._resources); + }; + await resourceCommand.watchResources([TYPES.REFLOW], { + onAvailable, + }); + + is(resources.length, 0, "No reflow resource were sent initially"); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const el = content.document.createElement("div"); + el.textContent = "1"; + content.document.body.appendChild(el); + }); + + await waitFor(() => resources.length === 1); + checkReflowResource(resources[0]); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const el = content.document.querySelector("div"); + el.style.display = "inline-grid"; + }); + + await waitFor(() => resources.length === 2); + ok( + true, + "A reflow resource is sent when the display property of an element is modified" + ); + checkReflowResource(resources.at(-1)); + + info("Check that adding an iframe does emit a reflow"); + const iframeBC = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + async () => { + const el = content.document.createElement("iframe"); + const onIframeLoaded = new Promise(resolve => + el.addEventListener("load", resolve, { once: true }) + ); + content.document.body.appendChild(el); + el.src = + "https://example.org/document-builder.sjs?html=<h2>remote iframe</h2>"; + await onIframeLoaded; + return el.browsingContext; + } + ); + + await waitFor(() => resources.length === 3); + ok(true, "A reflow resource was received when adding a remote iframe"); + checkReflowResource(resources.at(-1)); + + info("Check that we receive reflow resources for the remote iframe"); + await SpecialPowers.spawn(iframeBC, [], () => { + const el = content.document.createElement("section"); + el.textContent = "remote org iframe"; + el.style.display = "grid"; + content.document.body.appendChild(el); + }); + + await waitFor(() => resources.length === 4); + if (isFissionEnabled()) { + ok( + resources.at(-1).targetFront.url.includes("example.org"), + "The reflow resource is linked to the remote target" + ); + } + checkReflowResource(resources.at(-1)); + + targetCommand.destroy(); + await client.close(); +}); + +function checkReflowResource(resource) { + is( + resource.resourceType, + TYPES.REFLOW, + "The resource has the expected resourceType" + ); + + ok(Array.isArray(resource.reflows), "the `reflows` property is an array"); + for (const reflow of resource.reflows) { + is( + Number.isFinite(reflow.start), + true, + "reflow start property is a number" + ); + is(Number.isFinite(reflow.end), true, "reflow end property is a number"); + Assert.greaterOrEqual( + reflow.end, + reflow.start, + "end is greater than start" + ); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_root_node.js b/devtools/shared/commands/resource/tests/browser_resources_root_node.js new file mode 100644 index 0000000000..67ef5efd90 --- /dev/null +++ b/devtools/shared/commands/resource/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 ResourceCommand API around ROOT_NODE + +/** + * 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 command. + * + * 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, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const browser = gBrowser.selectedBrowser; + + info("Call watchResources([ROOT_NODE], ...)"); + let onAvailableCounter = 0; + const onAvailable = resources => (onAvailableCounter += resources.length); + await resourceCommand.watchResources([resourceCommand.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"); + resourceCommand.unwatchResources([resourceCommand.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 + targetCommand.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, resourceCommand, targetCommand } = await initResourceCommand( + 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 resourceCommand.watchResources([resourceCommand.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, + resourceCommand.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; + Assert.notStrictEqual( + 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.startLoadingURIString( + 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 + resourceCommand.unwatchResources([resourceCommand.TYPES.ROOT_NODE], { + onAvailable, + }); + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_scope_flag.js b/devtools/shared/commands/resource/tests/browser_resources_scope_flag.js new file mode 100644 index 0000000000..8537daf161 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_scope_flag.js @@ -0,0 +1,128 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the ResourceCommand clears its pending events for resources emitted from +// target destroyed when devtools.browsertoolbox.scope is updated. + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`<div id="test"></div>`); + +add_task(async function () { + // Do not run this test when both fission and EFT is disabled as it changes + // the number of targets + if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) { + ok(true, "Don't go further is both Fission and EFT are disabled"); + return; + } + + // 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); + + // Start with multiprocess debugging enabled + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + const { TYPES } = targetCommand; + + const targets = new Set(); + const onAvailable = async ({ targetFront }) => { + targets.add(targetFront); + }; + const onDestroyed = () => {}; + await targetCommand.watchTargets({ + types: [TYPES.PROCESS, TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + info("Open a tab in a new content process"); + const firstTab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + + const newTabInnerWindowId = + firstTab.linkedBrowser.browsingContext.currentWindowGlobal.innerWindowId; + + info("Wait for the tab window global target"); + const windowGlobalTarget = await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.FRAME && + target.innerWindowId == newTabInnerWindowId + ) + ); + + let gotTabResource = false; + const onResourceAvailable = resources => { + for (const resource of resources) { + if (resource.targetFront == windowGlobalTarget) { + gotTabResource = true; + + if (resource.targetFront.isDestroyed()) { + ok( + false, + "we shouldn't get resources for the target that was destroyed when switching mode" + ); + } + } + } + }; + + info("Start listening for resources"); + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: onResourceAvailable, + ignoreExistingResources: true, + } + ); + + // Emit logs every ms to fill up the resourceCommand resource queue (pendingEvents) + const intervalId = await SpecialPowers.spawn( + firstTab.linkedBrowser, + [], + () => { + let counter = 0; + return content.wrappedJSObject.setInterval(() => { + counter++; + content.wrappedJSObject.console.log("STREAM_" + counter); + }, 1); + } + ); + + info("Wait until we get the first resource"); + await waitFor(() => gotTabResource); + + info("Disable multiprocess debugging"); + await pushPref("devtools.browsertoolbox.scope", "parent-process"); + + info("Wait for the tab target to be destroyed"); + await waitFor(() => windowGlobalTarget.isDestroyed()); + + info("Wait for a bit so any throttled action would have the time to occur"); + await wait(1000); + + // Stop listening for resources + await commands.resourceCommand.unwatchResources( + [commands.resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: onResourceAvailable, + } + ); + // And stop the interval + await SpecialPowers.spawn(firstTab.linkedBrowser, [intervalId], id => { + content.wrappedJSObject.clearInterval(id); + }); + + targetCommand.destroy(); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js b/devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js new file mode 100644 index 0000000000..dab6c8d8cc --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around SERVER SENT EVENTS. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const targets = { + TOP_LEVEL_DOCUMENT: "top-level-document", + IN_PROCESS_IFRAME: "in-process-frame", + OUT_PROCESS_IFRAME: "out-process-frame", +}; + +add_task(async function () { + info("Testing the top-level document"); + await testServerSentEventResources(targets.TOP_LEVEL_DOCUMENT); + info("Testing the in-process iframe"); + await testServerSentEventResources(targets.IN_PROCESS_IFRAME); + info("Testing the out-of-process iframe"); + await testServerSentEventResources(targets.OUT_PROCESS_IFRAME); +}); + +async function testServerSentEventResources(target) { + const tab = await addTab(URL_ROOT_SSL + "sse_frontend.html"); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const availableResources = []; + + function onResourceAvailable(resources) { + availableResources.push(...resources); + } + + await resourceCommand.watchResources( + [resourceCommand.TYPES.SERVER_SENT_EVENT], + { onAvailable: onResourceAvailable } + ); + + openConnectionInContext(tab, target); + + info("Check available resources"); + // We expect only 2 resources + await waitUntil(() => availableResources.length === 2); + + info("Check resource details"); + // To make sure the channel id are the same + const httpChannelId = availableResources[0].httpChannelId; + + ok(httpChannelId, "The channel id is set"); + is(typeof httpChannelId, "number", "The channel id is a number"); + + assertResource(availableResources[0], { + messageType: "eventReceived", + httpChannelId, + data: { + payload: "Why so serious?", + eventName: "message", + lastEventId: "", + retry: 5000, + }, + }); + + assertResource(availableResources[1], { + messageType: "eventSourceConnectionClosed", + httpChannelId, + }); + + await resourceCommand.unwatchResources( + [resourceCommand.TYPES.SERVER_SENT_EVENT], + { onAvailable: onResourceAvailable } + ); + + await targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +} + +function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.SERVER_SENT_EVENT, + "Resource type is correct" + ); + + checkObject(resource, expected); +} + +async function openConnectionInContext(tab, target) { + let browsingContext = tab.linkedBrowser.browsingContext; + if (target !== targets.TOP_LEVEL_DOCUMENT) { + browsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [target], + async _target => { + const iframe = content.document.getElementById(_target); + return iframe.browsingContext; + } + ); + } + await SpecialPowers.spawn(browsingContext, [], async () => { + await content.wrappedJSObject.openConnection(); + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_several_resources.js b/devtools/shared/commands/resource/tests/browser_resources_several_resources.js new file mode 100644 index 0000000000..c1a151e562 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_several_resources.js @@ -0,0 +1,111 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Check that the resource command 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. + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // Open a test tab + const tab = await addTab("data:text/html,Root Node tests"); + + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const { CONSOLE_MESSAGE, ROOT_NODE } = resourceCommand.TYPES; + + // We are only interested in console messages as a resource, the ROOT_NODE one + // is here to test the ResourceCommand::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 resourceCommand.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 command captures resources from new targets. + info("Open a first tab on the example.com domain"); + const comTab = await addTab( + "https://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 resourceCommand.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.org domain"); + const orgTab = await addTab( + "https://example.org/document-builder.sjs?html=org" + ); + info("Use console.log in the example.org page"); + logInTab(orgTab, "test-from-example-org"); + info( + "Wait until onAvailable received the CONSOLE_MESSAGE resource emitted from the example.org tab" + ); + await waitUntil(() => + receivedMessages.find( + resource => resource.message.arguments[0] === "test-from-example-org" + ) + ); + + info("Stop watching CONSOLE_MESSAGE resources"); + await resourceCommand.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 command should not watch CONSOLE_MESSAGE anymore" + ); + + // Cleanup + targetCommand.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/commands/resource/tests/browser_resources_sources.js b/devtools/shared/commands/resource/tests/browser_resources_sources.js new file mode 100644 index 0000000000..767f45283a --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_sources.js @@ -0,0 +1,456 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around SOURCE. +// +// We cover each Spidermonkey Debugger Source's `introductionType`: +// https://searchfox.org/mozilla-central/rev/4c184ca81b28f1ccffbfd08f465709b95bcb4aa1/js/src/doc/Debugger/Debugger.Source.md#172-213 +// +// And especially cover sources being GC-ed before DevTools are opened +// which are later recreated by `ThreadActor.resurrectSource`. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const TEST_URL = URL_ROOT_SSL + "sources.html"; + +const TEST_JS_URL = URL_ROOT_SSL + "sources.js"; +const TEST_WORKER_URL = URL_ROOT_SSL + "worker-sources.js"; +const TEST_SW_URL = URL_ROOT_SSL + "service-worker-sources.js"; + +async function getExpectedResources(ignoreUnresurrectedSources = false) { + const htmlRequest = await fetch(TEST_URL); + const htmlContent = await htmlRequest.text(); + + // First list sources that aren't GC-ed, or that the thread actor is able to resurrect + const expectedSources = [ + { + description: "eval", + sourceForm: { + introductionType: "eval", + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "this.global = function evalFunction() {}", + }, + }, + { + description: "new Function()", + sourceForm: { + introductionType: "Function", + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "function anonymous(\n) {\nreturn 42;\n}", + }, + }, + { + description: "Event Handler", + sourceForm: { + introductionType: "eventHandler", + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "console.log('link')", + }, + }, + { + description: "inline JS inserted at runtime", + sourceForm: { + introductionType: "scriptElement", // This is an injectedScript at SpiderMonkey level, but is translated into scriptElement by SourceActor.form() + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "console.log('inline-script')", + }, + }, + { + description: "inline JS", + sourceForm: { + introductionType: "scriptElement", // This is an inlineScript at SpiderMonkey level, but is translated into scriptElement by SourceActor.form() + sourceMapBaseURL: TEST_URL, + url: TEST_URL, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: true, + }, + sourceContent: { + contentType: "text/html", + source: htmlContent, + }, + }, + { + description: "worker script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: TEST_WORKER_URL, + url: TEST_WORKER_URL, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction workerSource() {}\n", + }, + }, + { + description: "service worker script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: TEST_SW_URL, + url: TEST_SW_URL, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction serviceWorkerSource() {}\n", + }, + }, + { + description: "independent js file", + sourceForm: { + introductionType: "scriptElement", // This is an srcScript at SpiderMonkey level, but is translated into scriptElement by SourceActor.form() + sourceMapBaseURL: TEST_JS_URL, + url: TEST_JS_URL, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "/* eslint-disable */\nfunction scriptSource() {}\n", + }, + }, + ]; + + // Now list the sources that could be GC-ed for which the thread actor isn't able to resurrect. + // This is the sources that we can't assert when we fetch sources after the page is already loaded. + const unresurrectedSources = [ + { + description: "DOM Timer", + sourceForm: { + introductionType: "domTimer", + sourceMapBaseURL: TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + /* the domTimer is prefixed by many empty lines in order to be positioned at the same line + as in the HTML file where setTimeout is called. + This is probably done by SourceActor.actualText(). + So the array size here, should be updated to match the line number of setTimeout call */ + source: new Array(39).join("\n") + `console.log("timeout")`, + }, + }, + { + description: "javascript URL", + sourceForm: { + introductionType: "javascriptURL", + sourceMapBaseURL: isEveryFrameTargetEnabled() + ? "about:blank" + : TEST_URL, + url: null, + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "666", + }, + }, + { + description: "srcdoc attribute on iframes #1", + sourceForm: { + introductionType: "scriptElement", + // We do not assert url/sourceMapBaseURL as it includes the Debugger.Source.id + // which is random + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "console.log('srcdoc')", + }, + }, + { + description: "srcdoc attribute on iframes #2", + sourceForm: { + introductionType: "scriptElement", + // We do not assert url/sourceMapBaseURL as it includes the Debugger.Source.id + // which is random + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "console.log('srcdoc 2')", + }, + }, + ]; + + if (ignoreUnresurrectedSources) { + return expectedSources; + } + return expectedSources.concat(unresurrectedSources); +} + +add_task(async function testSourcesOnload() { + // Load an blank document first, in order to load the test page only once we already + // started watching for sources + const tab = await addTab("about:blank"); + + const commands = await CommandsFactory.forTab(tab); + const { targetCommand, resourceCommand } = commands; + + // Force the target list to cover workers and debug all the targets + targetCommand.listenForWorkers = true; + targetCommand.listenForServiceWorkers = true; + await targetCommand.startListening(); + + info("Check already available resources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], { + onAvailable: resources => availableResources.push(...resources), + }); + + const promiseLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + TEST_URL + ); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, TEST_URL); + await promiseLoad; + + // Some sources may be created after the document is done loading (like eventHandler usecase) + // so we may be received *after* watchResource resolved + const expectedResources = await getExpectedResources(); + await waitFor( + () => availableResources.length >= expectedResources.length, + "Got all the sources" + ); + + await assertResources(availableResources, expectedResources); + + await commands.destroy(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +add_task(async function testGarbagedCollectedSources() { + info( + "Assert SOURCES on an already loaded page with some sources that have been GC-ed" + ); + const tab = await addTab(TEST_URL); + + info("Force some GC to free some sources"); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + Cu.forceGC(); + Cu.forceCC(); + }); + + const commands = await CommandsFactory.forTab(tab); + const { targetCommand, resourceCommand } = commands; + + // Force the target list to cover workers and debug all the targets + targetCommand.listenForWorkers = true; + targetCommand.listenForServiceWorkers = true; + await targetCommand.startListening(); + + info("Check already available resources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], { + onAvailable: resources => availableResources.push(...resources), + }); + + // Some sources may be created after the document is done loading (like eventHandler usecase) + // so we may be received *after* watchResource resolved + const expectedResources = await getExpectedResources(true); + await waitFor( + () => availableResources.length >= expectedResources.length, + "Got all the sources" + ); + + await assertResources(availableResources, expectedResources); + + await commands.destroy(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +/** + * Assert that evaluating sources for a new global, in the parent process + * using the shared system principal will spawn SOURCE resources. + * + * For this we use a special `commands` which replicate what browser console + * and toolbox use. + */ +add_task(async function testParentProcessPrivilegedSources() { + // Use a custom loader + server + client in order to spawn the server + // in a distinct system compartment, so that it can see the system compartment + // sandbox we are about to create in this test + const client = await CommandsFactory.spawnClientToDebugSystemPrincipal(); + + const commands = await CommandsFactory.forMainProcess({ client }); + await commands.targetCommand.startListening(); + const { resourceCommand } = commands; + + info("Check already available resources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.SOURCE], { + onAvailable: resources => availableResources.push(...resources), + }); + ok( + !!availableResources.length, + "We get many sources reported from a multiprocess command" + ); + + // Clear the list of sources + availableResources.length = 0; + + // Force the creation of a new privileged source + const systemPrincipal = Cc["@mozilla.org/systemprincipal;1"].createInstance( + Ci.nsIPrincipal + ); + const sandbox = Cu.Sandbox(systemPrincipal); + Cu.evalInSandbox("function foo() {}", sandbox, null, "http://foo.com"); + + info("Wait for the sandbox source"); + await waitFor(() => { + return availableResources.some( + resource => resource.url == "http://foo.com/" + ); + }); + + const expectedResources = [ + { + description: "privileged sandbox script", + sourceForm: { + introductionType: undefined, + sourceMapBaseURL: "http://foo.com/", + url: "http://foo.com/", + isBlackBoxed: false, + sourceMapURL: null, + extensionName: null, + isInlineSource: false, + }, + sourceContent: { + contentType: "text/javascript", + source: "function foo() {}", + }, + }, + ]; + const matchingResource = availableResources.filter(resource => + resource.url.includes("http://foo.com") + ); + await assertResources(matchingResource, expectedResources); + + await commands.destroy(); +}); + +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) { + is( + source.resourceType, + ResourceCommand.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}"` + ); + info(`Found "#${matchingExpected.description}"`); + 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/commands/resource/tests/browser_resources_stylesheets.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets.js new file mode 100644 index 0000000000..ec81e8118d --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets.js @@ -0,0 +1,713 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around STYLESHEET. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +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/commands/resource/tests/style_document.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], + }, + { + styleText: "body { margin: 1px; }", + href: "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.css", + nodeHref: + "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], + }, + { + styleText: "", + href: null, + nodeHref: null, + isNew: false, + disabled: false, + constructed: true, + ruleCount: 1, + atRules: [], + }, + { + styleText: "body { background-color: pink; }", + href: null, + nodeHref: + "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], + }, + { + styleText: "body { padding: 1px; }", + href: "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.css", + nodeHref: + "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], + }, +]; + +const ADDITIONAL_INLINE_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/commands/resource/tests/style_document.html", + isNew: false, + disabled: false, + constructed: false, + ruleCount: 5, + atRules: [ + { + type: "media", + conditionText: "all", + matches: true, + line: 1, + column: 1, + }, + { + type: "media", + conditionText: "print", + matches: false, + line: 1, + column: 37, + }, + ], +}; + +const ADDITIONAL_CONSTRUCTED_RESOURCE = { + styleText: "", + href: null, + nodeHref: null, + isNew: false, + disabled: false, + constructed: true, + ruleCount: 2, + atRules: [], +}; + +const ADDITIONAL_FROM_ACTOR_RESOURCE = { + styleText: "body { font-size: 10px; }", + href: null, + nodeHref: + "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", + isNew: true, + disabled: false, + constructed: false, + ruleCount: 1, + atRules: [], +}; + +add_task(async function () { + await testResourceAvailableDestroyedFeature(); + await testResourceUpdateFeature(); + await testNestedResourceUpdateFeature(); +}); + +function pushAvailableResource(availableResources) { + // TODO(bug 1826538): Find a better way of dealing with these. + return function (resources) { + for (const resource of resources) { + if (resource.href?.startsWith("resource://")) { + continue; + } + availableResources.push(resource); + } + }; +} + +async function testResourceAvailableDestroyedFeature() { + info("Check resource available feature of the ResourceCommand"); + + const tab = await addTab(STYLE_TEST_URL); + let resourceTimingEntryCounts = await getResourceTimingCount(tab); + is( + resourceTimingEntryCounts, + 2, + "Should have two entires for resource timing" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + const destroyedResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: pushAvailableResource(availableResources), + onDestroyed: resources => destroyedResources.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); + } + + resourceTimingEntryCounts = await getResourceTimingCount(tab); + is( + resourceTimingEntryCounts, + 2, + "Should still have two entires for resource timing after devtools APIs have been triggered" + ); + + info("Check whether ResourceCommand gets additonal stylesheet"); + await ContentTask.spawn( + tab.linkedBrowser, + ADDITIONAL_INLINE_RESOURCE.styleText, + text => { + const document = content.document; + const stylesheet = document.createElement("style"); + stylesheet.id = "inline-from-test"; + stylesheet.textContent = text; + document.body.appendChild(stylesheet); + } + ); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 1 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_INLINE_RESOURCE + ); + + info("Check whether ResourceCommand gets additonal constructed stylesheet"); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + const document = content.document; + const s = new content.CSSStyleSheet(); + // We use the different number of rules to meaningfully differentiate + // between constructed stylesheets. + s.replaceSync("foo { color: red } bar { color: blue }"); + // TODO(bug 1751346): wrappedJSObject should be unnecessary. + document.wrappedJSObject.adoptedStyleSheets.push(s); + }); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 2 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_CONSTRUCTED_RESOURCE + ); + + info( + "Check whether ResourceCommand gets additonal stylesheet which is added by DevTools" + ); + const styleSheetsFront = await targetCommand.targetFront.getFront( + "stylesheets" + ); + await styleSheetsFront.addStyleSheet( + ADDITIONAL_FROM_ACTOR_RESOURCE.styleText + ); + await waitUntil( + () => availableResources.length === EXISTING_RESOURCES.length + 3 + ); + await assertResource( + availableResources[availableResources.length - 1], + ADDITIONAL_FROM_ACTOR_RESOURCE + ); + + info("Check resource destroyed feature of the ResourceCommand"); + is(destroyedResources.length, 0, "There was no removed stylesheets yet"); + + info("Remove inline stylesheet added in the test"); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + content.document.querySelector("#inline-from-test").remove(); + }); + await waitUntil(() => destroyedResources.length === 1); + assertDestroyed(destroyedResources[0], { + resourceId: availableResources.at(-3).resourceId, + }); + + info("Remove existing top-level inline stylesheet"); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + content.document.querySelector("style").remove(); + }); + await waitUntil(() => destroyedResources.length === 2); + assertDestroyed(destroyedResources[1], { + resourceId: availableResources.find( + resource => + findMatchingExpectedResource(resource) === EXISTING_RESOURCES[0] + ).resourceId, + }); + + info("Remove existing top-level <link> stylesheet"); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + content.document.querySelector("link").remove(); + }); + await waitUntil(() => destroyedResources.length === 3); + assertDestroyed(destroyedResources[2], { + resourceId: availableResources.find( + resource => + findMatchingExpectedResource(resource) === EXISTING_RESOURCES[1] + ).resourceId, + }); + + info("Remove existing iframe inline stylesheet"); + const iframeBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); + + await SpecialPowers.spawn(iframeBrowsingContext, [], () => { + content.document.querySelector("style").remove(); + }); + await waitUntil(() => destroyedResources.length === 4); + assertDestroyed(destroyedResources[3], { + resourceId: availableResources.find( + resource => + findMatchingExpectedResource(resource) === EXISTING_RESOURCES[3] + ).resourceId, + }); + + info("Remove existing iframe <link> stylesheet"); + await SpecialPowers.spawn(iframeBrowsingContext, [], () => { + content.document.querySelector("link").remove(); + }); + await waitUntil(() => destroyedResources.length === 5); + assertDestroyed(destroyedResources[4], { + resourceId: availableResources.find( + resource => + findMatchingExpectedResource(resource) === EXISTING_RESOURCES[4] + ).resourceId, + }); + + targetCommand.destroy(); + await client.close(); +} + +async function testResourceUpdateFeature() { + info("Check resource update feature of the ResourceCommand"); + + const tab = await addTab(STYLE_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Setup the watcher"); + const availableResources = []; + const updates = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: pushAvailableResource(availableResources), + onUpdated: newUpdates => updates.push(...newUpdates), + }); + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + is(updates.length, 0, "there's no update yet"); + + info("Check toggleDisabled function"); + // Retrieve the stylesheet of the top-level target + const resource = availableResources.find( + innerResource => innerResource.targetFront.isTopLevel + ); + 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 was updated correctly"); + + info("Check update function"); + const expectedAtRules = [ + { + type: "media", + conditionText: "screen", + matches: true, + }, + { + type: "media", + conditionText: "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: "at-rules-changed", + }); + assertAtRules(updates[3].update.resourceUpdates.atRules, expectedAtRules); + + // Check the actual page. + const styleSheetResult = await getStyleSheetResult(tab); + + is( + styleSheetResult.ruleCount, + 3, + "ruleCount of actual stylesheet is updated correctly" + ); + assertAtRules(styleSheetResult.atRules, expectedAtRules); + + targetCommand.destroy(); + await client.close(); +} + +async function testNestedResourceUpdateFeature() { + info("Check nested resource update feature of the ResourceCommand"); + + const tab = await addTab(STYLE_TEST_URL); + + const { outerWidth: originalWindowWidth, outerHeight: originalWindowHeight } = + tab.ownerGlobal; + + registerCleanupFunction(() => { + tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight); + }); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Setup the watcher"); + const availableResources = []; + const updates = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: pushAvailableResource(availableResources), + 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). + if (originalWindowHeight !== 300) { + await new Promise(resolve => { + tab.ownerGlobal.addEventListener("resize", resolve, { once: true }); + tab.ownerGlobal.resizeTo(originalWindowWidth, 300); + }); + } + + // Retrieve the stylesheet of the top-level target + const resource = availableResources.find( + innerResource => innerResource.targetFront.isTopLevel + ); + const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); + await styleSheetsFront.update( + resource.resourceId, + `@media (min-height: 400px) { + html { + color: red; + } + @layer myLayer { + @supports (container-type) { + :root { + color: gold; + container: root inline-size; + } + + @container root (width > 10px) { + body { + color: gold; + } + } + } + } + }`, + false + ); + await waitUntil(() => updates.length === 3); + is( + updates.at(-1).resource.ruleCount, + 7, + "Resource in update has expected ruleCount" + ); + + is(resource.atRules[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 targetUpdate = updates[3]; + assertUpdate(targetUpdate.update, { + resourceId: resource.resourceId, + updateType: "matches-change", + }); + Assert.strictEqual( + resource, + targetUpdate.resource, + "Update object has the same resource" + ); + + is( + JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path), + JSON.stringify(["atRules", 0, "matches"]), + "path of nestedResourceUpdates is correct" + ); + is( + targetUpdate.update.nestedResourceUpdates[0].value, + true, + "value of nestedResourceUpdates is correct" + ); + + // Check the resource. + const expectedAtRules = [ + { + type: "media", + conditionText: "(min-height: 400px)", + matches: true, + }, + { + type: "layer", + layerName: "myLayer", + }, + { + type: "support", + conditionText: "(container-type)", + }, + { + type: "container", + conditionText: "root (width > 10px)", + }, + ]; + + assertAtRules(targetUpdate.resource.atRules, expectedAtRules); + + // Check the actual page. + const styleSheetResult = await getStyleSheetResult(tab); + is( + styleSheetResult.ruleCount, + 7, + "ruleCount of actual stylesheet is updated correctly" + ); + assertAtRules(styleSheetResult.atRules, expectedAtRules); + + tab.ownerGlobal.resizeTo(originalWindowWidth, originalWindowHeight); + + targetCommand.destroy(); + await client.close(); +} + +function findMatchingExpectedResource(resource) { + return EXISTING_RESOURCES.find( + expected => + resource.href === expected.href && + resource.nodeHref === expected.nodeHref && + resource.ruleCount === expected.ruleCount && + resource.constructed == expected.constructed + ); +} + +async function getStyleSheetResult(tab) { + const result = await ContentTask.spawn(tab.linkedBrowser, null, () => { + const document = content.document; + const stylesheet = document.styleSheets[0]; + let ruleCount = 0; + const atRules = []; + + const traverseRules = ruleList => { + for (const rule of ruleList) { + ruleCount++; + + if (rule.media) { + let matches = false; + try { + const mql = content.matchMedia(rule.media.mediaText); + matches = mql.matches; + } catch (e) { + // Ignored + } + + atRules.push({ + type: "media", + conditionText: rule.conditionText, + matches, + }); + } else if (rule instanceof content.CSSContainerRule) { + atRules.push({ + type: "container", + conditionText: rule.conditionText, + }); + } else if (rule instanceof content.CSSLayerBlockRule) { + atRules.push({ type: "layer", layerName: rule.name }); + } else if (rule instanceof content.CSSSupportsRule) { + atRules.push({ + type: "support", + conditionText: rule.conditionText, + }); + } + + if (rule.cssRules) { + traverseRules(rule.cssRules); + } + } + }; + traverseRules(stylesheet.cssRules); + + return { ruleCount, atRules }; + }); + + return result; +} + +function assertAtRules(atRules, expectedAtRules) { + is( + atRules.length, + expectedAtRules.length, + "Length of the atRules is correct" + ); + + for (let i = 0; i < atRules.length; i++) { + const atRule = atRules[i]; + const expected = expectedAtRules[i]; + is(atRule.type, expected.type, "at-rule is of expected type"); + is( + atRules[i].conditionText, + expected.conditionText, + "conditionText is correct" + ); + if (expected.type === "media") { + is(atRule.matches, expected.matches, "matches is correct"); + } else if (expected.type === "layer") { + is(atRule.layerName, expected.layerName, "layerName is correct"); + } + + if (expected.line !== undefined) { + is(atRule.line, expected.line, "line is correct"); + } + + if (expected.column !== undefined) { + is(atRule.column, expected.column, "column is correct"); + } + } +} + +async function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.STYLESHEET, + "Resource type is correct" + ); + const styleText = (await getStyleSheetResourceText(resource)).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.constructed, expected.constructed, "constructed is correct"); + is(resource.ruleCount, expected.ruleCount, "ruleCount is correct"); + assertAtRules(resource.atRules, expected.atRules); +} + +function assertUpdate(update, expected) { + is( + update.resourceType, + ResourceCommand.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"); + } +} + +function assertDestroyed(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.STYLESHEET, + "Resource type is correct" + ); + is(resource.resourceId, expected.resourceId, "resourceId is correct"); +} + +function getResourceTimingCount(tab) { + return ContentTask.spawn(tab.linkedBrowser, [], () => { + return content.performance.getEntriesByType("resource").length; + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_header.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_header.js new file mode 100644 index 0000000000..29263d887b --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_header.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we do get the appropriate stylesheet content when the stylesheet is only +// served based on the Accept: text/css header + +add_task(async function () { + const httpServer = createTestHTTPServer(); + + httpServer.registerContentType("html", "text/html"); + + httpServer.registerPathHandler("/index.html", function (request, response) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write(` +<!DOCTYPE html> +<meta charset="utf-8"> +<title>Test stylesheet</title> +<link href="/test/" rel="stylesheet" type="text/css"/> +<script src="/test/"></script> +<h1>Hello</h1> + `); + }); + + let resourceUrlCalls = 0; + // The /test/ URL should be called: + // - once by the content page to load the <link> + // - once by the content page to load the <script> + // - once by DevTools to fetch the stylesheet text + // (we could probably optimize this so we only call once) + const expectedResourceUrlCalls = 3; + + const styleSheetText = `body { background-color: tomato; }`; + httpServer.registerPathHandler("/test/", function (request, response) { + resourceUrlCalls++; + response.setStatusLine(request.httpVersion, 200, "OK"); + + if (request.getHeader("Accept").startsWith("text/css")) { + response.setHeader("Content-Type", "text/css", false); + response.write(styleSheetText); + return; + } + response.setHeader("Content-Type", "application/javascript", false); + response.write(`/* NOT A STYLESHEET */`); + }); + const port = httpServer.identity.primaryPort; + const TEST_URL = `http://localhost:${port}/index.html`; + + info("Check resource available feature of the ResourceCommand"); + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + is( + availableResources.length, + 1, + "We have the expected number of stylesheets" + ); + + is( + await getStyleSheetResourceText(availableResources[0]), + styleSheetText, + "Got expected text for the stylesheet" + ); + + is( + resourceUrlCalls, + expectedResourceUrlCalls, + "The /test URL was called the number of time we expected" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js new file mode 100644 index 0000000000..c58a5162e0 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API for imported STYLESHEET + iframe. + +const styleSheetText = ` +@import "${URL_ROOT_ORG_SSL}/style_document.css"; +body { background-color: tomato; }`; + +const IFRAME_URL = `https://example.org/document-builder.sjs?html=${encodeURIComponent(` + <style>${styleSheetText}</style> + <h1>iframe</h1> +`)}`; + +const TEST_URL = `https://example.org/document-builder.sjs?html= + <h1>import stylesheet test</h1> + <iframe src="${encodeURIComponent(IFRAME_URL)}"></iframe>`; + +add_task(async function () { + info("Check resource available feature of the ResourceCommand"); + + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + + await waitFor(() => availableResources.length === 2); + ok(true, "We're getting the expected stylesheets"); + + const styleNodeStyleSheet = availableResources.find( + resource => resource.nodeHref + ); + const importedStyleSheet = availableResources.find( + resource => resource !== styleNodeStyleSheet + ); + + is( + await getStyleSheetResourceText(styleNodeStyleSheet), + styleSheetText, + "Got expected text for the <style> stylesheet" + ); + + is( + (await getStyleSheetResourceText(importedStyleSheet)).trim(), + `body { margin: 1px; }`, + "Got expected text for the imported stylesheet" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js new file mode 100644 index 0000000000..1ee8913bda --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js @@ -0,0 +1,254 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around STYLESHEET and navigation (reloading, creation of new browsing context, โฆ) + +const ORG_DOC_BUILDER = "https://example.org/document-builder.sjs"; +const COM_DOC_BUILDER = "https://example.com/document-builder.sjs"; + +// Since the order of resources is not guaranteed, we put a number in the title attribute +// of the <style> elements so we can sort them in a way that makes it easier for us to assert. +let currentStyleTitle = 0; + +const TEST_URI = + `${ORG_DOC_BUILDER}?html=1<h1>top-level example.org</h1>` + + `<style title="${currentStyleTitle++}">.top-level-org{}</style>` + + `<iframe id="same-origin-1" src="${ORG_DOC_BUILDER}?html=<h2>example.org 1</h2><style title=${currentStyleTitle++}>.frame-org-1{}</style>"></iframe>` + + `<iframe id="same-origin-2" src="${ORG_DOC_BUILDER}?html=<h2>example.org 2</h2><style title=${currentStyleTitle++}>.frame-org-2{}</style>"></iframe>` + + `<iframe id="remote-origin-1" src="${COM_DOC_BUILDER}?html=<h2>example.com 1</h2><style title=${currentStyleTitle++}>.frame-com-1{}</style>"></iframe>` + + `<iframe id="remote-origin-2" src="${COM_DOC_BUILDER}?html=<h2>example.com 2</h2><style title=${currentStyleTitle++}>.frame-com-2{}</style>"></iframe>`; + +const COOP_HEADERS = "Cross-Origin-Opener-Policy:same-origin"; +const TEST_URI_NEW_BROWSING_CONTEXT = + `${ORG_DOC_BUILDER}?headers=${COOP_HEADERS}` + + `&html=<h1>top-level example.org</div>` + + `<style>.top-level-org-new-bc{}</style>`; + +add_task(async function () { + info( + "Open a new tab and check that styleSheetChangeEventsEnabled is false by default" + ); + const tab = await addTab(TEST_URI); + + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + false, + `styleSheetChangeEventsEnabled is false at the beginning` + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + let availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => { + availableResources.push(...resources); + }, + }); + + info("Wait for all the stylesheets resources of main document and iframes"); + await waitFor(() => availableResources.length === 5); + is(availableResources.length, 5, "Retrieved the expected stylesheets"); + + // the order of the resources is not guaranteed. + sortResourcesByExpectedOrder(availableResources); + await assertResource(availableResources[0], { + styleText: `.top-level-org{}`, + }); + await assertResource(availableResources[1], { + styleText: `.frame-org-1{}`, + }); + await assertResource(availableResources[2], { + styleText: `.frame-org-2{}`, + }); + await assertResource(availableResources[3], { + styleText: `.frame-com-1{}`, + }); + await assertResource(availableResources[4], { + styleText: `.frame-com-2{}`, + }); + + // clear availableResources so it's easier to test + availableResources = []; + + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + true, + `styleSheetChangeEventsEnabled is true after watching stylesheets` + ); + + info("Navigate a remote frame to a different page"); + const iframeNewUrl = + `https://example.com/document-builder.sjs?` + + `html=<h2>example.com new bc</h2><style title=6>.frame-com-new-bc{}</style>`; + await SpecialPowers.spawn(tab.linkedBrowser, [iframeNewUrl], url => { + const { browsingContext } = + content.document.querySelector("#remote-origin-2"); + return SpecialPowers.spawn(browsingContext, [url], innerUrl => { + content.document.location = innerUrl; + }); + }); + await waitFor(() => availableResources.length == 1); + ok(true, "We're notified about the iframe new document stylesheet"); + await assertResource(availableResources[0], { + styleText: `.frame-com-new-bc{}`, + }); + const iframeNewBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("#remote-origin-2").browsingContext + ); + + is( + await getDocumentStyleSheetChangeEventsEnabled(iframeNewBrowsingContext), + true, + `styleSheetChangeEventsEnabled is still true after navigating the iframe` + ); + + // clear availableResources so it's easier to test + availableResources = []; + + info("Check that styleSheetChangeEventsEnabled persist after reloading"); + await reloadBrowser(); + + // โ ๏ธ When EFT is disabled, we're only getting the stylesheets for the top-level document + // and the remote frames; the same-origin iframes stylesheets are missing. + const expectedStylesheetResources = isEveryFrameTargetEnabled() ? 5 : 3; + info( + "Wait until we're notified about all the stylesheets (top-level document + iframe)" + ); + await waitFor( + () => availableResources.length === expectedStylesheetResources + ); + is( + availableResources.length, + expectedStylesheetResources, + "Retrieved the expected stylesheets after the page was reloaded" + ); + + // the order of the resources is not guaranteed. + sortResourcesByExpectedOrder(availableResources); + await assertResource(availableResources[0], { + styleText: `.top-level-org{}`, + }); + if (isEveryFrameTargetEnabled()) { + await assertResource(availableResources[1], { + styleText: `.frame-org-1{}`, + }); + await assertResource(availableResources[2], { + styleText: `.frame-org-2{}`, + }); + await assertResource(availableResources[3], { + styleText: `.frame-com-1{}`, + }); + await assertResource(availableResources[4], { + styleText: `.frame-com-new-bc{}`, + }); + } else { + await assertResource(availableResources[1], { + styleText: `.frame-com-1{}`, + }); + await assertResource(availableResources[2], { + styleText: `.frame-com-new-bc{}`, + }); + } + + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + true, + `styleSheetChangeEventsEnabled is still true on the top level document after reloading` + ); + + if (isEveryFrameTargetEnabled()) { + const bc = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("#same-origin-1").browsingContext + ); + is( + await getDocumentStyleSheetChangeEventsEnabled(bc), + true, + `styleSheetChangeEventsEnabled is still true on the iframe after reloading` + ); + } + + // clear availableResources so it's easier to test + availableResources = []; + + info( + "Check that styleSheetChangeEventsEnabled persist when navigating to a page that creates a new browsing context" + ); + const previousBrowsingContextId = tab.linkedBrowser.browsingContext.id; + const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + TEST_URI_NEW_BROWSING_CONTEXT + ); + await onLoaded; + + isnot( + tab.linkedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + info("Wait to get the stylesheet for the new document"); + await waitFor(() => availableResources.length === 1); + ok(true, "We received the stylesheet for the new document"); + await assertResource(availableResources[0], { + styleText: `.top-level-org-new-bc{}`, + }); + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + true, + `styleSheetChangeEventsEnabled is still true after navigating to a new browsing context` + ); + + targetCommand.destroy(); + await client.close(); +}); + +/** + * Returns the value of the browser/browsingContext document `styleSheetChangeEventsEnabled` + * property. + * + * @param {Browser|BrowsingContext} browserOrBrowsingContext: The browser element or a + * browsing context. + * @returns {Promise<Boolean>} + */ +function getDocumentStyleSheetChangeEventsEnabled(browserOrBrowsingContext) { + return SpecialPowers.spawn(browserOrBrowsingContext, [], () => { + return content.document.styleSheetChangeEventsEnabled; + }); +} + +/** + * Sort the passed array of stylesheet resources. + * + * Since the order of resources are not guaranteed, the <style> elements we use in this test + * have a "title" attribute that represent their expected order so we can sort them in + * a way that makes it easier for us to assert. + * + * @param {Array<Object>} resources: Array of stylesheet resources + */ +function sortResourcesByExpectedOrder(resources) { + resources.sort((a, b) => { + return Number(a.title) > Number(b.title); + }); +} + +/** + * Check that the resources have the expected text + * + * @param {Array<Object>} resources: Array of stylesheet resources + * @param {Array<Object>} expected: Array of object of the following shape: + * @param {Object} expected[] + * @param {Object} expected[].styleText: Expected text content of the stylesheet + */ +async function assertResource(resource, expected) { + const styleText = (await getStyleSheetResourceText(resource)).trim(); + is(styleText, expected.styleText, "Style text is correct"); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js new file mode 100644 index 0000000000..0b13f75ab9 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that stylesheets are retrieved even if an iframe does not have a content document. + +const TEST_URI = URL_ROOT_SSL + "stylesheets-nested-iframes.html"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + + // Bug 285395 limits the number of nested iframes to 10, and we have one stylesheet per document. + await waitFor(() => availableResources.length >= 10); + + is( + availableResources.length, + 10, + "Got the expected number of stylesheets, even with documentless iframes" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_target_destroy.js b/devtools/shared/commands/resource/tests/browser_resources_target_destroy.js new file mode 100644 index 0000000000..fa7813d26e --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_target_destroy.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the server ResourceCommand are destroyed when the associated target actors +// are destroyed. + +add_task(async function () { + const tab = await addTab("data:text/html,Test"); + const { client, resourceCommand, targetCommand } = await initResourceCommand( + 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 resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: () => {}, + } + ); + + info( + "Spawn a content task in order to be able to manipulate actors and resource watchers directly" + ); + const connectionPrefix = targetCommand.watcherFront.actorID.replace( + /watcher\d+$/, + "" + ); + await ContentTask.spawn( + tab.linkedBrowser, + [connectionPrefix], + function (_connectionPrefix) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { TargetActorRegistry } = ChromeUtils.importESModule( + "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs" + ); + const { + getResourceWatcher, + TYPES, + } = require("resource://devtools/server/actors/resources/index.js"); + + // Retrieve the target actor instance and its watcher for console messages + const targetActor = TargetActorRegistry.getTargetActors( + { + type: "browser-element", + browserId: content.browsingContext.browserId, + }, + _connectionPrefix + ).find(actor => actor.isTopLevelTarget); + ok( + targetActor, + "Got the top level target actor from the content process" + ); + 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"); + targetCommand.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.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + getResourceWatcher, + TYPES, + } = require("resource://devtools/server/actors/resources/index.js"); + + 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/commands/resource/tests/browser_resources_target_resources_race.js b/devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js new file mode 100644 index 0000000000..557d14380a --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * 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, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const expectedPlatformMessage = "expectedMessage"; + + info("Log a message *before* calling ResourceCommand.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 = resourceCommand.watchResources( + [resourceCommand.TYPES.CSS_MESSAGE], + { + onAvailable: onCssMessageAvailable, + } + ); + + // `waitForNextResource` will trigger another call to `watchResources`. + const { onResource: onMessageReceived } = + await resourceCommand.waitForNextResource( + resourceCommand.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. + resourceCommand.unwatchResources([resourceCommand.TYPES.CSS_MESSAGE], { + onAvailable: onCssMessageAvailable, + }); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_target_switching.js b/devtools/shared/commands/resource/tests/browser_resources_target_switching.js new file mode 100644 index 0000000000..4551fec778 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_target_switching.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the behavior of ResourceCommand when the top level target changes + +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, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + const { CONSOLE_MESSAGE, SOURCE } = resourceCommand.TYPES; + + info("Check the resources gotten from getAllResources at initial"); + is( + resourceCommand.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 => { + availableResources.push(...resources); + }; + await resourceCommand.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 command stop watching for targets + const onSourceAvailable = () => {}; + await resourceCommand.watchResources([SOURCE], { + onAvailable: onSourceAvailable, + }); + + info( + "Unregister the console listener and check that we no longer listen for console messages" + ); + resourceCommand.unwatchResources([CONSOLE_MESSAGE], { + onAvailable, + }); + + let onSwitched = targetCommand.once("switched-target"); + info("Navigate to another process"); + BrowserTestUtils.startLoadingURIString( + 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 = targetCommand.once("switched-target"); + BrowserTestUtils.startLoadingURIString(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( + resourceCommand.getAllResources(CONSOLE_MESSAGE).length, + 0, + "As we are no longer listening to CONSOLE message, we should not collect any" + ); + + resourceCommand.unwatchResources([SOURCE], { + onAvailable: onSourceAvailable, + }); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_thread_states.js b/devtools/shared/commands/resource/tests/browser_resources_thread_states.js new file mode 100644 index 0000000000..f915bb14d0 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_thread_states.js @@ -0,0 +1,557 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around THREAD_STATE + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const BREAKPOINT_TEST_URL = URL_ROOT_SSL + "breakpoint_document.html"; +const REMOTE_IFRAME_URL = + "https://example.org/document-builder.sjs?html=" + + encodeURIComponent("<script>debugger;</script>"); + +add_task(async function () { + // Check hitting the "debugger;" statement before and after calling + // watchResource(THREAD_TYPES). Both should break. First will + // be a cached resource and second will be a live one. + await checkBreakpointBeforeWatchResources(); + await checkBreakpointAfterWatchResources(); + + // Check setting a real breakpoint on a given line + await checkRealBreakpoint(); + + // Check the "pause on exception" setting + await checkPauseOnException(); + + // Check an edge case where spamming setBreakpoints calls causes issues + await checkSetBeforeWatch(); + + // Check debugger statement for (remote) iframes + await checkDebuggerStatementInIframes(); +}); + +async function checkBreakpointBeforeWatchResources() { + info( + "Check whether ResourceCommand gets existing breakpoint, being hit before calling watchResources" + ); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // Ensure that the target front is initialized early from TargetCommand.onTargetAvailable + // By the time `initResourceCommand` resolves, it should already be initialized. + info( + "Verify that TargetFront's initialized is resolved after having calling attachAndInitThread" + ); + await targetCommand.targetFront.initialized; + + info("Run the 'debugger' statement"); + // Note that we do not wait for the resolution of spawn as it will be paused + ContentTask.spawn(tab.linkedBrowser, null, () => { + content.window.wrappedJSObject.runDebuggerStatement(); + }); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 1, + "Got the THREAD_STATE's related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "debuggerStatement", + }, + frame: { + type: "call", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "runDebuggerStatement", + // arguments: [] + where: { + line: 17, + column: 6, + }, + }, + }); + + const { threadFront } = targetCommand.targetFront; + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function checkBreakpointAfterWatchResources() { + info( + "Check whether ResourceCommand gets breakpoint hit after calling watchResources" + ); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 0, + "Got no THREAD_STATE when calling watchResources" + ); + + info("Run the 'debugger' statement"); + // Note that we do not wait for the resolution of spawn as it will be paused + ContentTask.spawn(tab.linkedBrowser, null, () => { + content.window.wrappedJSObject.runDebuggerStatement(); + }); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "debuggerStatement", + }, + frame: { + type: "call", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "runDebuggerStatement", + // arguments: [] + where: { + line: 17, + column: 6, + }, + }, + }); + + // treadFront is created and attached while calling watchResources + const { threadFront } = targetCommand.targetFront; + + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function checkRealBreakpoint() { + info( + "Check whether ResourceCommand gets breakpoint set via the thread Front (instead of just debugger statements)" + ); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 0, + "Got no THREAD_STATE when calling watchResources" + ); + + // treadFront is created and attached while calling watchResources + const { threadFront } = targetCommand.targetFront; + + // We have to call `sources` request, otherwise the Thread Actor + // doesn't start watching for sources, and ignore the setBreakpoint call + // as it doesn't have any source registered. + await threadFront.getSources(); + + await threadFront.setBreakpoint( + { sourceUrl: BREAKPOINT_TEST_URL, line: 14 }, + {} + ); + + info("Run the test function where we set a breakpoint"); + // Note that we do not wait for the resolution of spawn as it will be paused + ContentTask.spawn(tab.linkedBrowser, null, () => { + content.window.wrappedJSObject.testFunction(); + }); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "breakpoint", + }, + frame: { + type: "call", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "testFunction", + // arguments: [] + where: { + line: 14, + column: 6, + }, + }, + }); + + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function checkPauseOnException() { + info( + "Check whether ResourceCommand gets breakpoint for exception (when explicitly requested)" + ); + + const tab = await addTab( + "data:text/html,<meta charset=utf8><script>a.b.c.d</script>" + ); + + const { commands, resourceCommand, targetCommand } = + await initResourceCommand(tab); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 0, + "Got no THREAD_STATE when calling watchResources" + ); + + await commands.threadConfigurationCommand.updateConfiguration({ + pauseOnExceptions: true, + }); + + info("Reload the page, in order to trigger exception on load"); + const reloaded = reloadBrowser(); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "exception", + }, + frame: { + type: "global", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "(global)", + // arguments: [] + where: { + line: 1, + column: 27, + }, + }, + }); + + const { threadFront } = targetCommand.targetFront; + await threadFront.resume(); + info("Wait for page to finish reloading after resume"); + await reloaded; + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await commands.destroy(); +} + +async function checkSetBeforeWatch() { + info( + "Verify bug 1683139 - D103068, where setting a breakpoint before watching for thread state, avoid receiving the paused state" + ); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // Instantiate the thread front in order to be able to set a breakpoint before watching for thread state + info("Attach the top level thread actor"); + await targetCommand.targetFront.attachAndInitThread(targetCommand); + const { threadFront } = targetCommand.targetFront; + + // We have to call `sources` request, otherwise the Thread Actor + // doesn't start watching for sources, and ignore the setBreakpoint call + // as it doesn't have any source registered. + await threadFront.getSources(); + + // Set the breakpoint before trying to hit it + await threadFront.setBreakpoint( + { sourceUrl: BREAKPOINT_TEST_URL, line: 14, column: 6 }, + {} + ); + + info("Run the test function where we set a breakpoint"); + // Note that we do not wait for the resolution of spawn as it will be paused + ContentTask.spawn(tab.linkedBrowser, null, () => { + content.window.wrappedJSObject.testFunction(); + }); + + // bug 1683139 - D103068. Re-setting the breakpoint just before watching for thread state + // prevented to receive the paused state change. + await threadFront.setBreakpoint( + { sourceUrl: BREAKPOINT_TEST_URL, line: 14, column: 6 }, + {} + ); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "breakpoint", + }, + frame: { + type: "call", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "testFunction", + // arguments: [] + where: { + line: 14, + column: 6, + }, + }, + }); + + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function checkDebuggerStatementInIframes() { + info("Check whether ResourceCommand gets breakpoint for (remote) iframes"); + + const tab = await addTab(BREAKPOINT_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Call watchResources"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.THREAD_STATE], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + 0, + "Got no THREAD_STATE when calling watchResources" + ); + + info("Inject the iframe with an inline 'debugger' statement"); + // Note that we do not wait for the resolution of spawn as it will be paused + SpecialPowers.spawn( + gBrowser.selectedBrowser, + [REMOTE_IFRAME_URL], + async function (url) { + const iframe = content.document.createElement("iframe"); + iframe.src = url; + content.document.body.appendChild(iframe); + } + ); + + await waitFor( + () => availableResources.length == 1, + "Got the THREAD_STATE related to the iframe's debugger statement" + ); + const threadState = availableResources.pop(); + + assertPausedResource(threadState, { + state: "paused", + why: { + type: "debuggerStatement", + }, + frame: { + type: "global", + asyncCause: null, + state: "on-stack", + // this: object actor's form referring to `this` variable + displayName: "(global)", + // arguments: [] + where: { + line: 1, + column: 8, + }, + }, + }); + + const iframeTarget = threadState.targetFront; + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + is( + iframeTarget.url, + REMOTE_IFRAME_URL, + "With fission/EFT, the pause is from the iframe's target" + ); + } else { + is( + iframeTarget, + targetCommand.targetFront, + "Without fission/EFT, the pause is from the top level target" + ); + } + const { threadFront } = iframeTarget; + + await threadFront.resume(); + + await waitFor( + () => availableResources.length == 1, + "Wait until we receive the resumed event" + ); + + const resumed = availableResources.pop(); + + assertResumedResource(resumed); + + targetCommand.destroy(); + await client.close(); +} + +async function assertPausedResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.THREAD_STATE, + "Resource type is correct" + ); + is(resource.state, "paused", "state attribute is correct"); + is(resource.why.type, expected.why.type, "why.type attribute is correct"); + is( + resource.frame.type, + expected.frame.type, + "frame.type attribute is correct" + ); + is( + resource.frame.asyncCause, + expected.frame.asyncCause, + "frame.asyncCause attribute is correct" + ); + is( + resource.frame.state, + expected.frame.state, + "frame.state attribute is correct" + ); + is( + resource.frame.displayName, + expected.frame.displayName, + "frame.displayName attribute is correct" + ); + is( + resource.frame.where.line, + expected.frame.where.line, + "frame.where.line attribute is correct" + ); + is( + resource.frame.where.column, + expected.frame.where.column, + "frame.where.column attribute is correct" + ); +} + +async function assertResumedResource(resource) { + is( + resource.resourceType, + ResourceCommand.TYPES.THREAD_STATE, + "Resource type is correct" + ); + is(resource.state, "resumed", "state attribute is correct"); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js b/devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js new file mode 100644 index 0000000000..e3890cf970 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that calling unwatchResources before watchResources could resolve still +// removes watcher entries correctly. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const TEST_URI = "data:text/html;charset=utf-8,"; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + const { CONSOLE_MESSAGE, ROOT_NODE } = resourceCommand.TYPES; + + info("Use console.log in the content page"); + await logInTab(tab, "msg-1"); + + info("Call watchResources with various configurations"); + + // Watcher 1 only watches for CONSOLE_MESSAGE. + // For this call site, unwatchResource will be called before onAvailable has + // resolved. + const messages1 = []; + const onAvailable1 = createMessageCallback(messages1); + const onWatcher1Ready = resourceCommand.watchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable1, + }); + resourceCommand.unwatchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable1, + }); + + info( + "Calling unwatchResources for an already unregistered callback should be a no-op" + ); + // and more importantly, it should not throw + resourceCommand.unwatchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable1, + }); + + // Watcher 2 watches for CONSOLE_MESSAGE & another resource (ROOT_NODE). + // Again unwatchResource will be called before onAvailable has resolved. + // But unwatchResource is only called for CONSOLE_MESSAGE, not for ROOT_NODE. + const messages2 = []; + const onAvailable2 = createMessageCallback(messages2); + const onWatcher2Ready = resourceCommand.watchResources( + [CONSOLE_MESSAGE, ROOT_NODE], + { + onAvailable: onAvailable2, + } + ); + resourceCommand.unwatchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable2, + }); + + // Watcher 3 watches for CONSOLE_MESSAGE, but we will not call unwatchResource + // explicitly for it before the end of test. Used as a reference. + const messages3 = []; + const onAvailable3 = createMessageCallback(messages3); + const onWatcher3Ready = resourceCommand.watchResources([CONSOLE_MESSAGE], { + onAvailable: onAvailable3, + }); + + info("Call unwatchResources for CONSOLE_MESSAGE on watcher 1 & 2"); + + info("Wait for all watchers `watchResources` to resolve"); + await Promise.all([onWatcher1Ready, onWatcher2Ready, onWatcher3Ready]); + ok(!hasMessage(messages1, "msg-1"), "Watcher 1 did not receive msg-1"); + ok(!hasMessage(messages2, "msg-1"), "Watcher 2 did not receive msg-1"); + ok(hasMessage(messages3, "msg-1"), "Watcher 3 received msg-1"); + + info("Log a new message"); + await logInTab(tab, "msg-2"); + + info("Wait until watcher 3 received the new message"); + await waitUntil(() => hasMessage(messages3, "msg-2")); + + ok(!hasMessage(messages1, "msg-2"), "Watcher 1 did not receive msg-2"); + ok(!hasMessage(messages2, "msg-2"), "Watcher 2 did not receive msg-2"); + + targetCommand.destroy(); + await client.close(); +}); + +function logInTab(tab, message) { + return ContentTask.spawn(tab.linkedBrowser, message, function (_message) { + content.console.log(_message); + }); +} + +function hasMessage(messageResources, text) { + return messageResources.find( + resource => resource.message.arguments[0] === text + ); +} + +// All resource command callbacks share the same pattern here: they add all +// console message resources to a provided `messages` array. +function createMessageCallback(messages) { + const { CONSOLE_MESSAGE } = ResourceCommand.TYPES; + return async resources => { + for (const resource of resources) { + if (resource.resourceType === CONSOLE_MESSAGE) { + messages.push(resource); + } + } + }; +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js b/devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js new file mode 100644 index 0000000000..cc45e7bf7f --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js @@ -0,0 +1,88 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that watching/unwatching multiple times works as expected + +add_task(async function () { + const TEST_URL = "data:text/html;charset=utf-8,<!DOCTYPE html>foo"; + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + let resources = []; + const onAvailable = _resources => { + resources.push(..._resources); + }; + + info("Watch for error messages resources"); + await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + ok( + resourceCommand.isResourceWatched(resourceCommand.TYPES.ERROR_MESSAGE), + "The error message resource is currently been watched." + ); + + is( + resources.length, + 0, + "no resources were received after the first watchResources call" + ); + + info("Trigger an error in the page"); + await ContentTask.spawn(tab.linkedBrowser, [], function frameScript() { + const document = content.document; + const scriptEl = document.createElement("script"); + scriptEl.textContent = `document.unknownFunction()`; + document.body.appendChild(scriptEl); + }); + + await waitFor(() => resources.length === 1); + const EXPECTED_ERROR_MESSAGE = + "TypeError: document.unknownFunction is not a function"; + is( + resources[0].pageError.errorMessage, + EXPECTED_ERROR_MESSAGE, + "The resource was received" + ); + + info("Unwatching resourcesโฆ"); + resourceCommand.unwatchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + ok( + !resourceCommand.isResourceWatched(resourceCommand.TYPES.ERROR_MESSAGE), + "The error message resource is no longer been watched." + ); + // clearing resources + resources = []; + + info("โฆand watching again"); + await resourceCommand.watchResources([resourceCommand.TYPES.ERROR_MESSAGE], { + onAvailable, + }); + + ok( + resourceCommand.isResourceWatched(resourceCommand.TYPES.ERROR_MESSAGE), + "The error message resource is been watched again." + ); + is( + resources.length, + 1, + "we retrieve the expected number of existing resources" + ); + is( + resources[0].pageError.errorMessage, + EXPECTED_ERROR_MESSAGE, + "The resource is the expected one" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_websocket.js b/devtools/shared/commands/resource/tests/browser_resources_websocket.js new file mode 100644 index 0000000000..601620bc59 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_websocket.js @@ -0,0 +1,245 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around WEBSOCKET. + +const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const IS_NUMBER = "IS_NUMBER"; +const SHOULD_EXIST = "SHOULD_EXIST"; + +const targets = { + TOP_LEVEL_DOCUMENT: "top-level-document", + IN_PROCESS_IFRAME: "in-process-frame", + OUT_PROCESS_IFRAME: "out-process-frame", +}; + +add_task(async function () { + info("Testing the top-level document"); + await testWebsocketResources(targets.TOP_LEVEL_DOCUMENT); + info("Testing the in-process iframe"); + await testWebsocketResources(targets.IN_PROCESS_IFRAME); + info("Testing the out-of-process iframe"); + await testWebsocketResources(targets.OUT_PROCESS_IFRAME); +}); + +async function testWebsocketResources(target) { + const tab = await addTab(URL_ROOT_SSL + "websocket_frontend.html"); + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const availableResources = []; + function onResourceAvailable(resources) { + availableResources.push(...resources); + } + + await resourceCommand.watchResources([resourceCommand.TYPES.WEBSOCKET], { + onAvailable: onResourceAvailable, + }); + + info("Check available resources at initial"); + is( + availableResources.length, + 0, + "Length of existing resources is correct at initial" + ); + + info("Check resource of opening websocket"); + await executeFunctionInContext(tab, target, "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: + "wss://example.com/browser/devtools/shared/commands/resource/tests/websocket_backend", + extensions: "permessage-deflate", + protocols: "", + }); + + info("Check resource of sending/receiving the data via websocket"); + await executeFunctionInContext(tab, target, "sendData", "test"); + + await waitUntil(() => availableResources.length === 3); + + assertResource(availableResources[1], { + wsMessageType: "frameSent", + httpChannelId, + data: { + type: "sent", + payload: "test", + timeStamp: SHOULD_EXIST, + finBit: SHOULD_EXIST, + rsvBit1: SHOULD_EXIST, + rsvBit2: SHOULD_EXIST, + rsvBit3: SHOULD_EXIST, + opCode: SHOULD_EXIST, + mask: SHOULD_EXIST, + maskBit: SHOULD_EXIST, + }, + }); + assertResource(availableResources[2], { + wsMessageType: "frameReceived", + httpChannelId, + data: { + type: "received", + payload: "test", + timeStamp: SHOULD_EXIST, + finBit: SHOULD_EXIST, + rsvBit1: SHOULD_EXIST, + rsvBit2: SHOULD_EXIST, + rsvBit3: SHOULD_EXIST, + opCode: SHOULD_EXIST, + mask: SHOULD_EXIST, + maskBit: SHOULD_EXIST, + }, + }); + + info("Check resource of closing websocket"); + await executeFunctionInContext(tab, target, "closeConnection"); + + await waitUntil(() => availableResources.length === 6); + assertResource(availableResources[3], { + wsMessageType: "frameSent", + httpChannelId, + data: { + type: "sent", + payload: "", + timeStamp: SHOULD_EXIST, + finBit: SHOULD_EXIST, + rsvBit1: SHOULD_EXIST, + rsvBit2: SHOULD_EXIST, + rsvBit3: SHOULD_EXIST, + opCode: SHOULD_EXIST, + mask: SHOULD_EXIST, + maskBit: SHOULD_EXIST, + }, + }); + assertResource(availableResources[4], { + wsMessageType: "frameReceived", + httpChannelId, + data: { + type: "received", + payload: "", + timeStamp: SHOULD_EXIST, + finBit: SHOULD_EXIST, + rsvBit1: SHOULD_EXIST, + rsvBit2: SHOULD_EXIST, + rsvBit3: SHOULD_EXIST, + opCode: SHOULD_EXIST, + mask: SHOULD_EXIST, + maskBit: SHOULD_EXIST, + }, + }); + assertResource(availableResources[5], { + wsMessageType: "webSocketClosed", + httpChannelId, + code: IS_NUMBER, + reason: "", + wasClean: true, + }); + + info("Check existing resources"); + const existingResources = []; + + function onExsistingResourceAvailable(resources) { + existingResources.push(...resources); + } + + await resourceCommand.watchResources([resourceCommand.TYPES.WEBSOCKET], { + onAvailable: onExsistingResourceAvailable, + }); + + is( + availableResources.length, + existingResources.length, + "Length of existing resources is correct" + ); + + for (let i = 0; i < availableResources.length; i++) { + Assert.strictEqual( + availableResources[i], + existingResources[i], + `The ${i}th resource is correct` + ); + } + + await resourceCommand.unwatchResources([resourceCommand.TYPES.WEBSOCKET], { + onAvailable: onResourceAvailable, + }); + + await resourceCommand.unwatchResources([resourceCommand.TYPES.WEBSOCKET], { + onAvailable: onExsistingResourceAvailable, + }); + + targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +} + +/** + * Execute global functions defined in the correct + * target (top-level-window or frames) contexts. + * + * @param {object} tab The current window tab + * @param {string} target A string identify if we want to test the top level document or iframes + * @param {string} funcName The name of the global function which needs to be called. + * @param {*} funcArgs The arguments to pass to the global function + */ +async function executeFunctionInContext(tab, target, funcName, ...funcArgs) { + let browsingContext = tab.linkedBrowser.browsingContext; + // If the target is an iframe get its window global + if (target !== targets.TOP_LEVEL_DOCUMENT) { + browsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [target], + async _target => { + const iframe = content.document.getElementById(_target); + return iframe.browsingContext; + } + ); + } + + return SpecialPowers.spawn( + browsingContext, + [funcName, funcArgs], + async (_funcName, _funcArgs) => { + await content.wrappedJSObject[_funcName](..._funcArgs); + } + ); +} + +function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.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] === SHOULD_EXIST) { + Assert.notStrictEqual( + object[field], + undefined, + `The value of ${field} exists` + ); + } 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/commands/resource/tests/doc_console.html b/devtools/shared/commands/resource/tests/doc_console.html new file mode 100644 index 0000000000..ee883cf47d --- /dev/null +++ b/devtools/shared/commands/resource/tests/doc_console.html @@ -0,0 +1,18 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf8"> + <title>Test document for console</title> + <!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +</head> + <body> + <p>Test document for console</p> + + <iframe src="data:text/html;charset=utf-8,foo<script>console.log('data url data log')</script>"></iframe> + <script> + "use strict"; + console.log("top-level document log"); + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/doc_console_iframe.html b/devtools/shared/commands/resource/tests/doc_console_iframe.html new file mode 100644 index 0000000000..e088dff4e5 --- /dev/null +++ b/devtools/shared/commands/resource/tests/doc_console_iframe.html @@ -0,0 +1,16 @@ +<!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/ --> +</head> + <body> + <p>remote iframe</p> + <script> + "use strict"; + console.log(`${document.location.origin} iframe log`); + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/early_console_document.html b/devtools/shared/commands/resource/tests/early_console_document.html new file mode 100644 index 0000000000..e4523dbdeb --- /dev/null +++ b/devtools/shared/commands/resource/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/commands/resource/tests/empty.html b/devtools/shared/commands/resource/tests/empty.html new file mode 100644 index 0000000000..195b296bfe --- /dev/null +++ b/devtools/shared/commands/resource/tests/empty.html @@ -0,0 +1,11 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> + +<!DOCTYPE HTML> +<html> +<head> + <meta charset="UTF-8"> + <title>Empty page (No network requests)</title> +</head> +<body></body> +</html> diff --git a/devtools/shared/commands/resource/tests/fission_document.html b/devtools/shared/commands/resource/tests/fission_document.html new file mode 100644 index 0000000000..222f92d999 --- /dev/null +++ b/devtools/shared/commands/resource/tests/fission_document.html @@ -0,0 +1,23 @@ +<!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/ --> +</head> +<body> +<p>Test fission iframe</p> + +<script> + "use strict"; + const iframe = document.createElement("iframe"); + let iframeUrl = `https://example.org/browser/devtools/shared/commands/resource/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/commands/resource/tests/fission_document_workers.html b/devtools/shared/commands/resource/tests/fission_document_workers.html new file mode 100644 index 0000000000..bbbe3e8bf8 --- /dev/null +++ b/devtools/shared/commands/resource/tests/fission_document_workers.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/commands/resource/tests/test_worker.js#simple-worker"); + + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker("https://example.com/browser/devtools/shared/commands/resource/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/commands/resource/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/commands/resource/tests/fission_iframe_workers.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/commands/resource/tests/fission_iframe.html b/devtools/shared/commands/resource/tests/fission_iframe.html new file mode 100644 index 0000000000..f674321102 --- /dev/null +++ b/devtools/shared/commands/resource/tests/fission_iframe.html @@ -0,0 +1,12 @@ +<!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/ --> +</head> +<body> +<p>remote iframe</p> +</body> +</html> diff --git a/devtools/shared/commands/resource/tests/fission_iframe_workers.html b/devtools/shared/commands/resource/tests/fission_iframe_workers.html new file mode 100644 index 0000000000..deae49f833 --- /dev/null +++ b/devtools/shared/commands/resource/tests/fission_iframe_workers.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/commands/resource/tests/head.js b/devtools/shared/commands/resource/tests/head.js new file mode 100644 index 0000000000..5cee383070 --- /dev/null +++ b/devtools/shared/commands/resource/tests/head.js @@ -0,0 +1,151 @@ +/* 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"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +async function _initResourceCommandFromCommands( + commands, + { listenForWorkers = false } = {} +) { + const targetCommand = commands.targetCommand; + if (listenForWorkers) { + targetCommand.listenForWorkers = true; + } + await targetCommand.startListening(); + + //Bug 1709065: Stop exporting resourceCommand and use commands.resourceCommand + //And rename all these methods + return { + client: commands.client, + commands, + resourceCommand: commands.resourceCommand, + targetCommand, + }; +} + +/** + * Instantiate a ResourceCommand 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 {ResourceCommand} object.resourceCommand + * The underlying resource command interface. + * @return {Object} object.commands + * The commands object defined by modules from devtools/shared/commands. + * @return {DevToolsClient} object.client + * The underlying client instance. + * @return {TargetCommand} object.targetCommand + * The underlying target list instance. + */ +async function initResourceCommand(tab, options) { + const commands = await CommandsFactory.forTab(tab); + return _initResourceCommandFromCommands(commands, options); +} + +/** + * Instantiate a multi-process ResourceCommand, watching all type of targets. + * + * @return {Object} object + * @return {ResourceCommand} object.resourceCommand + * The underlying resource command interface. + * @return {Object} object.commands + * The commands object defined by modules from devtools/shared/commands. + * @return {DevToolsClient} object.client + * The underlying client instance. + * @return {DevToolsClient} object.targetCommand + * The underlying target list instance. + */ +async function initMultiProcessResourceCommand() { + const commands = await CommandsFactory.forMainProcess(); + return _initResourceCommandFromCommands(commands); +} + +// 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 (expected === undefined) { + is(value, expected, `'${name}' is undefined`); + } 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(); + }); + } +} + +/** + * Get the stylesheet text for a given stylesheet resource. + * + * @param {Object} styleSheetResource + * @returns Promise<String> + */ +async function getStyleSheetResourceText(styleSheetResource) { + const styleSheetsFront = await styleSheetResource.targetFront.getFront( + "stylesheets" + ); + const res = await styleSheetsFront.getText(styleSheetResource.resourceId); + return res.string(); +} diff --git a/devtools/shared/commands/resource/tests/network_document.html b/devtools/shared/commands/resource/tests/network_document.html new file mode 100644 index 0000000000..5c4744cb0c --- /dev/null +++ b/devtools/shared/commands/resource/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/commands/resource/tests/network_document_navigation.html b/devtools/shared/commands/resource/tests/network_document_navigation.html new file mode 100644 index 0000000000..c4ec651c05 --- /dev/null +++ b/devtools/shared/commands/resource/tests/network_document_navigation.html @@ -0,0 +1,14 @@ +<!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> + <script src="network_navigation.js" /> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/network_navigation.js b/devtools/shared/commands/resource/tests/network_navigation.js new file mode 100644 index 0000000000..6004b69d3c --- /dev/null +++ b/devtools/shared/commands/resource/tests/network_navigation.js @@ -0,0 +1 @@ +// empty script loaded by network_document_navigation.html diff --git a/devtools/shared/commands/resource/tests/service-worker-sources.js b/devtools/shared/commands/resource/tests/service-worker-sources.js new file mode 100644 index 0000000000..614644ee5d --- /dev/null +++ b/devtools/shared/commands/resource/tests/service-worker-sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function serviceWorkerSource() {} diff --git a/devtools/shared/commands/resource/tests/sources.html b/devtools/shared/commands/resource/tests/sources.html new file mode 100644 index 0000000000..9e1ad67d85 --- /dev/null +++ b/devtools/shared/commands/resource/tests/sources.html @@ -0,0 +1,53 @@ +<!-- 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> + <!-- introductionType=eventHandler --> + <div onclick="console.log('link')">link</div> + + <!-- introductionType=inlineScript mapped to scriptElement --> + <script type="text/javascript"> + "use strict"; + /* eslint-disable */ + function inlineSource() {} + + // introductionType=eval + // Assign it to a global in order to avoid it being GCed + eval("this.global = function evalFunction() {}"); + + // introductionType=Function + // Also assign to a global to avoid being GCed + this.global2 = new Function("return 42;"); + + // introductionType=injectedScript mapped to scriptElement + const script = document.createElement("script"); + script.textContent = "console.log('inline-script')"; + document.documentElement.appendChild(script); + + // introductionType=Worker, but ends up being null on SourceActor's form + // 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"); + + // introductionType=domTimer + setTimeout(`console.log("timeout")`, 0); + + // introductionType=eventHandler + window.addEventListener("DOMContentLoaded", () => { + document.querySelector("div[onclick]").click(); + }); + </script> + <!-- introductionType=srcScript mapped to scriptElement --> + <script src="sources.js"></script> + <!-- introductionType=javascriptURL --> + <iframe src="javascript:666"></iframe> + <!-- srcdoc attribute on iframes --> + <iframe srcdoc="<script>console.log('srcdoc')</script> <script>console.log('srcdoc 2')</script>"></iframe> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/sources.js b/devtools/shared/commands/resource/tests/sources.js new file mode 100644 index 0000000000..7ae6c6272b --- /dev/null +++ b/devtools/shared/commands/resource/tests/sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function scriptSource() {} diff --git a/devtools/shared/commands/resource/tests/sse_backend.sjs b/devtools/shared/commands/resource/tests/sse_backend.sjs new file mode 100644 index 0000000000..777520577a --- /dev/null +++ b/devtools/shared/commands/resource/tests/sse_backend.sjs @@ -0,0 +1,8 @@ +"use strict"; + +function handleRequest(request, response) { + response.processAsync(); + response.setHeader("Content-Type", "text/event-stream"); + response.write("data: Why so serious?\n\n"); + response.finish(); +} diff --git a/devtools/shared/commands/resource/tests/sse_frontend.html b/devtools/shared/commands/resource/tests/sse_frontend.html new file mode 100644 index 0000000000..3bdddbc5bc --- /dev/null +++ b/devtools/shared/commands/resource/tests/sse_frontend.html @@ -0,0 +1,31 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <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>SSE Inspection Test Page</title> + </head> + <body> + <h1>SSE Inspection Test Page</h1> + <script type="text/javascript"> + "use strict"; + + /* exported openConnection */ + function openConnection() { + return new Promise(resolve => { + const es = new EventSource("sse_backend.sjs"); + es.onmessage = function (e) { + es.close(); + resolve(); + }; + }); + } + </script> + <iframe id="in-process-frame" src="https://example.com/browser/devtools/shared/commands/resource/tests/sse_frontend_iframe.html"> </iframe> + <iframe id="out-process-frame" src="https://example.org/browser/devtools/shared/commands/resource/tests/sse_frontend_iframe.html"></iframe> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/sse_frontend_iframe.html b/devtools/shared/commands/resource/tests/sse_frontend_iframe.html new file mode 100644 index 0000000000..477dca013d --- /dev/null +++ b/devtools/shared/commands/resource/tests/sse_frontend_iframe.html @@ -0,0 +1,29 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <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>SSE Inspection Test Page in iframe</title> + </head> + <body> + <h1>SSE Inspection Test Page in Iframe</h1> + <script type="text/javascript"> + "use strict"; + + /* exported openConnection */ + function openConnection() { + return new Promise(resolve => { + const es = new EventSource("sse_backend.sjs"); + es.onmessage = function (e) { + es.close(); + resolve(); + }; + }); + } + </script> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/style_document.css b/devtools/shared/commands/resource/tests/style_document.css new file mode 100644 index 0000000000..aa54533924 --- /dev/null +++ b/devtools/shared/commands/resource/tests/style_document.css @@ -0,0 +1 @@ +body { margin: 1px; } diff --git a/devtools/shared/commands/resource/tests/style_document.html b/devtools/shared/commands/resource/tests/style_document.html new file mode 100644 index 0000000000..deaf6c4248 --- /dev/null +++ b/devtools/shared/commands/resource/tests/style_document.html @@ -0,0 +1,22 @@ +<!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"> + <script> + "use strict"; + const s = new CSSStyleSheet(); + s.replaceSync("body { background-color: blue }"); + document.adoptedStyleSheets.push(s); + </script> + </head> + <body> + <iframe src="https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html"></iframe> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/style_iframe.css b/devtools/shared/commands/resource/tests/style_iframe.css new file mode 100644 index 0000000000..30e7ae802b --- /dev/null +++ b/devtools/shared/commands/resource/tests/style_iframe.css @@ -0,0 +1 @@ +body { padding: 1px; } diff --git a/devtools/shared/commands/resource/tests/style_iframe.html b/devtools/shared/commands/resource/tests/style_iframe.html new file mode 100644 index 0000000000..11ad9f785b --- /dev/null +++ b/devtools/shared/commands/resource/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/commands/resource/tests/stylesheets-nested-iframes.html b/devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html new file mode 100644 index 0000000000..eb6c371867 --- /dev/null +++ b/devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html @@ -0,0 +1,27 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>StyleSheetsActor iframe test</title> + <style> + p { + padding: 1em; + } + </style> +</head> +<body> + <p>A test page with nested iframes</p> + <iframe></iframe> + <script type="application/javascript"> + "use strict"; + + const iframe = document.querySelector("iframe"); + let i = parseInt(location.href.split("?")[1], 10) || 1; + + // The frame can't have the same src URL as any of its ancestors. + // This will not infinitely recurse because a frame won't get a content + // document once it's nested deeply enough. + iframe.src = location.href.split("?")[0] + "?" + (++i); + </script> +</body> +</html> diff --git a/devtools/shared/commands/resource/tests/test_image.png b/devtools/shared/commands/resource/tests/test_image.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/devtools/shared/commands/resource/tests/test_image.png diff --git a/devtools/shared/commands/resource/tests/test_service_worker.js b/devtools/shared/commands/resource/tests/test_service_worker.js new file mode 100644 index 0000000000..aabc3fda0f --- /dev/null +++ b/devtools/shared/commands/resource/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/commands/resource/tests/test_worker.js b/devtools/shared/commands/resource/tests/test_worker.js new file mode 100644 index 0000000000..60ccc6d52b --- /dev/null +++ b/devtools/shared/commands/resource/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/commands/resource/tests/websocket_backend_wsh.py b/devtools/shared/commands/resource/tests/websocket_backend_wsh.py new file mode 100644 index 0000000000..170f15fe6c --- /dev/null +++ b/devtools/shared/commands/resource/tests/websocket_backend_wsh.py @@ -0,0 +1,20 @@ +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/commands/resource/tests/websocket_frontend.html b/devtools/shared/commands/resource/tests/websocket_frontend.html new file mode 100644 index 0000000000..7efe11f9eb --- /dev/null +++ b/devtools/shared/commands/resource/tests/websocket_frontend.html @@ -0,0 +1,45 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8" /> + <title>Websocket Inspection Test Page</title> + </head> + <body> + <h1>Websocket Inspection Test Page</h1> + <script type="text/javascript"> + /* exported openConnection, closeConnection, sendData */ + "use strict"; + + let webSocket; + function openConnection() { + return new Promise(resolve => { + webSocket = new WebSocket( + "wss://example.com/browser/devtools/shared/commands/resource/tests/websocket_backend" + ); + webSocket.onopen = () => { + resolve(); + }; + }); + } + + function closeConnection() { + return new Promise(resolve => { + webSocket.onclose = () => { + resolve(); + }; + webSocket.close(); + }) + } + + function sendData(payload) { + webSocket.send(payload); + } + </script> + <iframe id="in-process-frame" + src="https://example.com/browser/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html"></iframe> + <iframe id="out-process-frame" + src="https://example.org/browser/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html"></iframe> + </body> +</html> diff --git a/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html b/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html new file mode 100644 index 0000000000..e18576f911 --- /dev/null +++ b/devtools/shared/commands/resource/tests/websocket_frontend_iframe.html @@ -0,0 +1,41 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype HTML> +<html> + <head> + <meta charset="utf-8"/> + <title>Websocket Inspection Test Page</title> + </head> + <body> + <h1>Websocket Inspection Test Page</h1> + <script type="text/javascript"> + /* exported openConnection, closeConnection, sendData */ + "use strict"; + + let webSocket; + function openConnection() { + return new Promise(resolve => { + webSocket = new WebSocket( + "wss://example.com/browser/devtools/shared/commands/resource/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/commands/resource/tests/worker-sources.js b/devtools/shared/commands/resource/tests/worker-sources.js new file mode 100644 index 0000000000..dcf2ed8031 --- /dev/null +++ b/devtools/shared/commands/resource/tests/worker-sources.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +function workerSource() {} |