From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- devtools/shared/commands/README.md | 51 + devtools/shared/commands/commands-factory.js | 245 ++++ devtools/shared/commands/create-command.sh | 129 ++ devtools/shared/commands/index.js | 135 ++ .../inspected-window/inspected-window-command.js | 145 +++ .../shared/commands/inspected-window/moz.build | 10 + .../commands/inspected-window/tests/browser.toml | 20 + .../tests/browser_webextension_inspected_window.js | 523 ++++++++ ...browser_webextension_inspected_window_access.js | 315 +++++ .../shared/commands/inspected-window/tests/head.js | 12 + .../tests/inspectedwindow-reload-target.sjs | 87 ++ .../shared/commands/inspector/inspector-command.js | 483 +++++++ devtools/shared/commands/inspector/moz.build | 10 + .../shared/commands/inspector/tests/browser.toml | 22 + ...inspector_command_findNodeFrontFromSelectors.js | 140 ++ ...command_getNodeFrontSelectorsFromTopDocument.js | 119 ++ ...ser_inspector_command_getSuggestionsForQuery.js | 124 ++ .../tests/browser_inspector_command_search.js | 98 ++ devtools/shared/commands/inspector/tests/head.js | 14 + devtools/shared/commands/moz.build | 22 + devtools/shared/commands/network/moz.build | 10 + .../shared/commands/network/network-command.js | 96 ++ .../shared/commands/network/tests/browser.toml | 13 + .../browser_network_command_request_blocking.js | 61 + .../browser_network_command_sendHTTPRequest.js | 78 ++ devtools/shared/commands/network/tests/head.js | 12 + devtools/shared/commands/object/moz.build | 10 + devtools/shared/commands/object/object-command.js | 63 + devtools/shared/commands/object/tests/browser.toml | 9 + .../shared/commands/object/tests/browser_object.js | 125 ++ devtools/shared/commands/object/tests/head.js | 12 + .../resource/legacy-listeners/console-messages.js | 59 + .../resource/legacy-listeners/css-changes.js | 28 + .../resource/legacy-listeners/error-messages.js | 62 + .../commands/resource/legacy-listeners/moz.build | 14 + .../resource/legacy-listeners/platform-messages.js | 44 + .../commands/resource/legacy-listeners/reflow.js | 24 + .../resource/legacy-listeners/root-node.js | 61 + .../commands/resource/legacy-listeners/source.js | 88 ++ .../resource/legacy-listeners/thread-states.js | 81 ++ devtools/shared/commands/resource/moz.build | 15 + .../shared/commands/resource/resource-command.js | 1367 ++++++++++++++++++++ .../resource/tests/breakpoint_document.html | 21 + .../shared/commands/resource/tests/browser.toml | 128 ++ .../browser_browser_resources_console_messages.js | 87 ++ .../tests/browser_resources_clear_resources.js | 90 ++ .../tests/browser_resources_client_caching.js | 380 ++++++ .../tests/browser_resources_console_messages.js | 623 +++++++++ ...rowser_resources_console_messages_navigation.js | 190 +++ .../browser_resources_console_messages_workers.js | 257 ++++ .../tests/browser_resources_css_changes.js | 151 +++ .../tests/browser_resources_css_messages.js | 212 +++ .../browser_resources_css_registered_properties.js | 384 ++++++ .../tests/browser_resources_document_events.js | 720 +++++++++++ .../tests/browser_resources_error_messages.js | 877 +++++++++++++ .../tests/browser_resources_getAllResources.js | 128 ++ .../tests/browser_resources_invalid_api_usage.js | 76 ++ .../browser_resources_last_private_context_exit.js | 94 ++ .../browser_resources_network_event_stacktraces.js | 100 ++ .../tests/browser_resources_network_events.js | 318 +++++ .../browser_resources_network_events_cache.js | 236 ++++ .../browser_resources_network_events_navigation.js | 137 ++ ...wser_resources_network_events_parent_process.js | 249 ++++ .../tests/browser_resources_platform_messages.js | 158 +++ .../resource/tests/browser_resources_reflows.js | 115 ++ .../resource/tests/browser_resources_root_node.js | 129 ++ .../resource/tests/browser_resources_scope_flag.js | 128 ++ .../tests/browser_resources_server_sent_events.js | 107 ++ .../tests/browser_resources_several_resources.js | 111 ++ .../resource/tests/browser_resources_sources.js | 456 +++++++ .../tests/browser_resources_stylesheets.js | 713 ++++++++++ .../tests/browser_resources_stylesheets_header.js | 82 ++ .../tests/browser_resources_stylesheets_import.js | 60 + .../browser_resources_stylesheets_navigation.js | 254 ++++ ...browser_resources_stylesheets_nested_iframes.js | 34 + .../tests/browser_resources_target_destroy.js | 104 ++ .../browser_resources_target_resources_race.js | 70 + .../tests/browser_resources_target_switching.js | 94 ++ .../tests/browser_resources_thread_states.js | 557 ++++++++ .../tests/browser_resources_unwatch_early.js | 113 ++ .../browser_resources_watch_unwatch_multiple.js | 88 ++ .../resource/tests/browser_resources_websocket.js | 245 ++++ .../commands/resource/tests/doc_console.html | 18 + .../resource/tests/doc_console_iframe.html | 16 + .../resource/tests/early_console_document.html | 14 + devtools/shared/commands/resource/tests/empty.html | 11 + .../commands/resource/tests/fission_document.html | 23 + .../resource/tests/fission_document_workers.html | 47 + .../commands/resource/tests/fission_iframe.html | 12 + .../resource/tests/fission_iframe_workers.html | 29 + devtools/shared/commands/resource/tests/head.js | 151 +++ .../commands/resource/tests/network_document.html | 13 + .../tests/network_document_navigation.html | 14 + .../commands/resource/tests/network_navigation.js | 1 + .../resource/tests/service-worker-sources.js | 2 + .../shared/commands/resource/tests/sources.html | 53 + devtools/shared/commands/resource/tests/sources.js | 2 + .../shared/commands/resource/tests/sse_backend.sjs | 8 + .../commands/resource/tests/sse_frontend.html | 31 + .../resource/tests/sse_frontend_iframe.html | 29 + .../commands/resource/tests/style_document.css | 1 + .../commands/resource/tests/style_document.html | 22 + .../commands/resource/tests/style_iframe.css | 1 + .../commands/resource/tests/style_iframe.html | 15 + .../resource/tests/stylesheets-nested-iframes.html | 27 + .../shared/commands/resource/tests/test_image.png | Bin 0 -> 580 bytes .../commands/resource/tests/test_service_worker.js | 11 + .../shared/commands/resource/tests/test_worker.js | 15 + .../resource/tests/websocket_backend_wsh.py | 20 + .../resource/tests/websocket_frontend.html | 45 + .../resource/tests/websocket_frontend_iframe.html | 41 + .../commands/resource/tests/worker-sources.js | 2 + .../resource/transformers/console-messages.js | 23 + .../resource/transformers/error-messages.js | 31 + .../commands/resource/transformers/moz.build | 16 + .../resource/transformers/network-events.js | 16 + .../resource/transformers/storage-cache.js | 22 + .../resource/transformers/storage-cookie.js | 26 + .../resource/transformers/storage-extension.js | 26 + .../resource/transformers/storage-indexed-db.js | 26 + .../resource/transformers/storage-local-storage.js | 22 + .../transformers/storage-session-storage.js | 22 + .../resource/transformers/thread-states.js | 32 + devtools/shared/commands/root-resource/moz.build | 7 + .../root-resource/root-resource-command.js | 348 +++++ devtools/shared/commands/script/moz.build | 10 + devtools/shared/commands/script/script-command.js | 157 +++ devtools/shared/commands/script/tests/browser.toml | 15 + .../tests/browser_script_command_execute_basic.js | 1050 +++++++++++++++ ...ser_script_command_execute_document__proto__.js | 41 + .../browser_script_command_execute_last_result.js | 85 ++ .../tests/browser_script_command_execute_throw.js | 75 ++ devtools/shared/commands/script/tests/head.js | 51 + .../shared/commands/target-configuration/moz.build | 10 + .../target-configuration-command.js | 124 ++ .../target-configuration/tests/browser.toml | 34 + .../tests/browser_target_configuration_command.js | 107 ++ ...er_target_configuration_command_color_scheme.js | 183 +++ ...rget_configuration_command_custom_user_agent.js | 309 +++++ .../browser_target_configuration_command_dppx.js | 187 +++ ...er_target_configuration_command_touch_events.js | 264 ++++ .../commands/target-configuration/tests/head.js | 12 + .../tests/target_configuration_test_doc.sjs | 100 ++ devtools/shared/commands/target/actions/moz.build | 7 + devtools/shared/commands/target/actions/targets.js | 33 + .../legacy-processes-watcher.js | 72 ++ .../legacy-serviceworkers-watcher.js | 302 +++++ .../legacy-sharedworkers-watcher.js | 19 + .../legacy-workers-watcher.js | 234 ++++ .../target/legacy-target-watchers/moz.build | 10 + devtools/shared/commands/target/moz.build | 17 + devtools/shared/commands/target/reducers/moz.build | 7 + .../shared/commands/target/reducers/targets.js | 70 + .../shared/commands/target/selectors/moz.build | 7 + .../shared/commands/target/selectors/targets.js | 20 + devtools/shared/commands/target/target-command.js | 1167 +++++++++++++++++ devtools/shared/commands/target/tests/browser.toml | 67 + .../target/tests/browser_target_command_bfcache.js | 505 ++++++++ .../browser_target_command_browser_workers.js | 246 ++++ .../target/tests/browser_target_command_detach.js | 59 + .../target/tests/browser_target_command_frames.js | 649 ++++++++++ .../tests/browser_target_command_frames_popups.js | 168 +++ ...et_command_frames_reload_server_side_targets.js | 104 ++ .../tests/browser_target_command_getAllTargets.js | 119 ++ .../browser_target_command_invalid_api_usage.js | 78 ++ .../tests/browser_target_command_processes.js | 242 ++++ .../target/tests/browser_target_command_reload.js | 115 ++ .../tests/browser_target_command_scope_flag.js | 190 +++ .../browser_target_command_service_workers.js | 77 ++ ...er_target_command_service_workers_navigation.js | 358 +++++ .../tests/browser_target_command_switchToTarget.js | 138 ++ .../tests/browser_target_command_tab_workers.js | 322 +++++ ...arget_command_tab_workers_bfcache_navigation.js | 134 ++ .../browser_target_command_various_descriptors.js | 284 ++++ .../tests/browser_target_command_watchTargets.js | 214 +++ .../tests/browser_watcher_actor_getter_caching.js | 87 ++ .../commands/target/tests/fission_document.html | 47 + .../commands/target/tests/fission_iframe.html | 29 + devtools/shared/commands/target/tests/head.js | 32 + .../target/tests/incremental-js-value-script.sjs | 23 + .../commands/target/tests/simple_document.html | 12 + .../commands/target/tests/test_service_worker.js | 11 + .../shared/commands/target/tests/test_sw_page.html | 19 + .../commands/target/tests/test_sw_page_worker.js | 5 + .../shared/commands/target/tests/test_worker.js | 13 + .../shared/commands/thread-configuration/moz.build | 7 + .../thread-configuration/tests/browser.toml | 7 + .../commands/thread-configuration/tests/head.js | 12 + .../thread-configuration-command.js | 72 ++ devtools/shared/commands/tracer/moz.build | 7 + devtools/shared/commands/tracer/tracer-command.js | 85 ++ 191 files changed, 24719 insertions(+) create mode 100644 devtools/shared/commands/README.md create mode 100644 devtools/shared/commands/commands-factory.js create mode 100755 devtools/shared/commands/create-command.sh create mode 100644 devtools/shared/commands/index.js create mode 100644 devtools/shared/commands/inspected-window/inspected-window-command.js create mode 100644 devtools/shared/commands/inspected-window/moz.build create mode 100644 devtools/shared/commands/inspected-window/tests/browser.toml create mode 100644 devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window.js create mode 100644 devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window_access.js create mode 100644 devtools/shared/commands/inspected-window/tests/head.js create mode 100644 devtools/shared/commands/inspected-window/tests/inspectedwindow-reload-target.sjs create mode 100644 devtools/shared/commands/inspector/inspector-command.js create mode 100644 devtools/shared/commands/inspector/moz.build create mode 100644 devtools/shared/commands/inspector/tests/browser.toml create mode 100644 devtools/shared/commands/inspector/tests/browser_inspector_command_findNodeFrontFromSelectors.js create mode 100644 devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js create mode 100644 devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js create mode 100644 devtools/shared/commands/inspector/tests/browser_inspector_command_search.js create mode 100644 devtools/shared/commands/inspector/tests/head.js create mode 100644 devtools/shared/commands/moz.build create mode 100644 devtools/shared/commands/network/moz.build create mode 100644 devtools/shared/commands/network/network-command.js create mode 100644 devtools/shared/commands/network/tests/browser.toml create mode 100644 devtools/shared/commands/network/tests/browser_network_command_request_blocking.js create mode 100644 devtools/shared/commands/network/tests/browser_network_command_sendHTTPRequest.js create mode 100644 devtools/shared/commands/network/tests/head.js create mode 100644 devtools/shared/commands/object/moz.build create mode 100644 devtools/shared/commands/object/object-command.js create mode 100644 devtools/shared/commands/object/tests/browser.toml create mode 100644 devtools/shared/commands/object/tests/browser_object.js create mode 100644 devtools/shared/commands/object/tests/head.js create mode 100644 devtools/shared/commands/resource/legacy-listeners/console-messages.js create mode 100644 devtools/shared/commands/resource/legacy-listeners/css-changes.js create mode 100644 devtools/shared/commands/resource/legacy-listeners/error-messages.js create mode 100644 devtools/shared/commands/resource/legacy-listeners/moz.build create mode 100644 devtools/shared/commands/resource/legacy-listeners/platform-messages.js create mode 100644 devtools/shared/commands/resource/legacy-listeners/reflow.js create mode 100644 devtools/shared/commands/resource/legacy-listeners/root-node.js create mode 100644 devtools/shared/commands/resource/legacy-listeners/source.js create mode 100644 devtools/shared/commands/resource/legacy-listeners/thread-states.js create mode 100644 devtools/shared/commands/resource/moz.build create mode 100644 devtools/shared/commands/resource/resource-command.js create mode 100644 devtools/shared/commands/resource/tests/breakpoint_document.html create mode 100644 devtools/shared/commands/resource/tests/browser.toml create mode 100644 devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_clear_resources.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_client_caching.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_console_messages.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_css_changes.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_css_messages.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_css_registered_properties.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_document_events.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_error_messages.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_getAllResources.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_invalid_api_usage.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_last_private_context_exit.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_network_event_stacktraces.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_network_events.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_network_events_cache.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_network_events_navigation.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_network_events_parent_process.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_platform_messages.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_reflows.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_root_node.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_scope_flag.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_server_sent_events.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_several_resources.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_sources.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_stylesheets.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_stylesheets_header.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_stylesheets_navigation.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_stylesheets_nested_iframes.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_target_destroy.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_target_resources_race.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_target_switching.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_thread_states.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_unwatch_early.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_watch_unwatch_multiple.js create mode 100644 devtools/shared/commands/resource/tests/browser_resources_websocket.js create mode 100644 devtools/shared/commands/resource/tests/doc_console.html create mode 100644 devtools/shared/commands/resource/tests/doc_console_iframe.html create mode 100644 devtools/shared/commands/resource/tests/early_console_document.html create mode 100644 devtools/shared/commands/resource/tests/empty.html create mode 100644 devtools/shared/commands/resource/tests/fission_document.html create mode 100644 devtools/shared/commands/resource/tests/fission_document_workers.html create mode 100644 devtools/shared/commands/resource/tests/fission_iframe.html create mode 100644 devtools/shared/commands/resource/tests/fission_iframe_workers.html create mode 100644 devtools/shared/commands/resource/tests/head.js create mode 100644 devtools/shared/commands/resource/tests/network_document.html create mode 100644 devtools/shared/commands/resource/tests/network_document_navigation.html create mode 100644 devtools/shared/commands/resource/tests/network_navigation.js create mode 100644 devtools/shared/commands/resource/tests/service-worker-sources.js create mode 100644 devtools/shared/commands/resource/tests/sources.html create mode 100644 devtools/shared/commands/resource/tests/sources.js create mode 100644 devtools/shared/commands/resource/tests/sse_backend.sjs create mode 100644 devtools/shared/commands/resource/tests/sse_frontend.html create mode 100644 devtools/shared/commands/resource/tests/sse_frontend_iframe.html create mode 100644 devtools/shared/commands/resource/tests/style_document.css create mode 100644 devtools/shared/commands/resource/tests/style_document.html create mode 100644 devtools/shared/commands/resource/tests/style_iframe.css create mode 100644 devtools/shared/commands/resource/tests/style_iframe.html create mode 100644 devtools/shared/commands/resource/tests/stylesheets-nested-iframes.html create mode 100644 devtools/shared/commands/resource/tests/test_image.png create mode 100644 devtools/shared/commands/resource/tests/test_service_worker.js create mode 100644 devtools/shared/commands/resource/tests/test_worker.js create mode 100644 devtools/shared/commands/resource/tests/websocket_backend_wsh.py create mode 100644 devtools/shared/commands/resource/tests/websocket_frontend.html create mode 100644 devtools/shared/commands/resource/tests/websocket_frontend_iframe.html create mode 100644 devtools/shared/commands/resource/tests/worker-sources.js create mode 100644 devtools/shared/commands/resource/transformers/console-messages.js create mode 100644 devtools/shared/commands/resource/transformers/error-messages.js create mode 100644 devtools/shared/commands/resource/transformers/moz.build create mode 100644 devtools/shared/commands/resource/transformers/network-events.js create mode 100644 devtools/shared/commands/resource/transformers/storage-cache.js create mode 100644 devtools/shared/commands/resource/transformers/storage-cookie.js create mode 100644 devtools/shared/commands/resource/transformers/storage-extension.js create mode 100644 devtools/shared/commands/resource/transformers/storage-indexed-db.js create mode 100644 devtools/shared/commands/resource/transformers/storage-local-storage.js create mode 100644 devtools/shared/commands/resource/transformers/storage-session-storage.js create mode 100644 devtools/shared/commands/resource/transformers/thread-states.js create mode 100644 devtools/shared/commands/root-resource/moz.build create mode 100644 devtools/shared/commands/root-resource/root-resource-command.js create mode 100644 devtools/shared/commands/script/moz.build create mode 100644 devtools/shared/commands/script/script-command.js create mode 100644 devtools/shared/commands/script/tests/browser.toml create mode 100644 devtools/shared/commands/script/tests/browser_script_command_execute_basic.js create mode 100644 devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js create mode 100644 devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js create mode 100644 devtools/shared/commands/script/tests/browser_script_command_execute_throw.js create mode 100644 devtools/shared/commands/script/tests/head.js create mode 100644 devtools/shared/commands/target-configuration/moz.build create mode 100644 devtools/shared/commands/target-configuration/target-configuration-command.js create mode 100644 devtools/shared/commands/target-configuration/tests/browser.toml create mode 100644 devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js create mode 100644 devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js create mode 100644 devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js create mode 100644 devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js create mode 100644 devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js create mode 100644 devtools/shared/commands/target-configuration/tests/head.js create mode 100644 devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs create mode 100644 devtools/shared/commands/target/actions/moz.build create mode 100644 devtools/shared/commands/target/actions/targets.js create mode 100644 devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js create mode 100644 devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js create mode 100644 devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js create mode 100644 devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js create mode 100644 devtools/shared/commands/target/legacy-target-watchers/moz.build create mode 100644 devtools/shared/commands/target/moz.build create mode 100644 devtools/shared/commands/target/reducers/moz.build create mode 100644 devtools/shared/commands/target/reducers/targets.js create mode 100644 devtools/shared/commands/target/selectors/moz.build create mode 100644 devtools/shared/commands/target/selectors/targets.js create mode 100644 devtools/shared/commands/target/target-command.js create mode 100644 devtools/shared/commands/target/tests/browser.toml create mode 100644 devtools/shared/commands/target/tests/browser_target_command_bfcache.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_browser_workers.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_detach.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_frames.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_frames_popups.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_processes.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_reload.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_scope_flag.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_service_workers.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_tab_workers.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js create mode 100644 devtools/shared/commands/target/tests/browser_target_command_watchTargets.js create mode 100644 devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js create mode 100644 devtools/shared/commands/target/tests/fission_document.html create mode 100644 devtools/shared/commands/target/tests/fission_iframe.html create mode 100644 devtools/shared/commands/target/tests/head.js create mode 100644 devtools/shared/commands/target/tests/incremental-js-value-script.sjs create mode 100644 devtools/shared/commands/target/tests/simple_document.html create mode 100644 devtools/shared/commands/target/tests/test_service_worker.js create mode 100644 devtools/shared/commands/target/tests/test_sw_page.html create mode 100644 devtools/shared/commands/target/tests/test_sw_page_worker.js create mode 100644 devtools/shared/commands/target/tests/test_worker.js create mode 100644 devtools/shared/commands/thread-configuration/moz.build create mode 100644 devtools/shared/commands/thread-configuration/tests/browser.toml create mode 100644 devtools/shared/commands/thread-configuration/tests/head.js create mode 100644 devtools/shared/commands/thread-configuration/thread-configuration-command.js create mode 100644 devtools/shared/commands/tracer/moz.build create mode 100644 devtools/shared/commands/tracer/tracer-command.js (limited to 'devtools/shared/commands') diff --git a/devtools/shared/commands/README.md b/devtools/shared/commands/README.md new file mode 100644 index 0000000000..8ed199828d --- /dev/null +++ b/devtools/shared/commands/README.md @@ -0,0 +1,51 @@ +# Commands + +Commands are singletons, which can be easily used by any frontend code. +They are meant to be exposed widely to the frontend so that any code can easily call any of their methods. + +Commands classes expose static methods, which: +* route to the right Front/Actor's method +* handle backward compatibility +* map to many target's actor if needed + +These classes are instantiated once per descriptor +and may have inner state, emit events, fire callbacks,... + +A transient backward compat need, required by Fission refactorings will be to have some code checking a trait, and either: +* call a single method on a parent process actor (like BreakpointListActor.setBreakpoint) +* otherwise, call a method on each target's scoped actor (like ThreadActor.setBreakpoint, that, for each available target) + +Without such layer, we would have to put such code here and there in the frontend code. +This will be harder to remove later, once we get rid of old pre-fission-refactoring codepaths. + +This layer already exists in some panels, but we are using slightly different names and practices: +* Debugger uses "client" (devtools/client/debugger/src/client/) and "commands" (devtools/client/debugger/src/client/firefox/commands.js) + Debugger's commands already bundle the code to dispatch an action to many target's actor. + They also contain some backward compat code. + Today, we pass around a `client` object via thunkArgs, which is mapped to commands.js, + instead we could pass a debugger command object. +* Network Monitor uses "connector" (devtools/client/netmonitor/src/connector) + Connectors also bundles backward compat and dispatch to many target's actor. + Today, we pass the `connector` to all middlewares from configureStore, + we could instead pass the netmonitor command object. +* Web Console has: + * devtools/client/webconsole/actions/input.js:handleHelperResult(), where we have to put some code, which is a duplicate of Netmonitor Connector, + and could be shared via a netmonitor command class. +* Inspector is probably the panel doing the most dispatch to many target's actor. + Codes using getAllInspectorFronts could all be migrated to an inspector command class: + https://searchfox.org/mozilla-central/search?q=symbol:%23getAllInspectorFronts&redirect=false + and simplify a bit the frontend. + It is also one panel, which still register listener to each target's inspector/walker fronts. + Because inspector isn't using resources. + But this work, registering listeners for each target might be done by such layer and translate the many actor's event into a unified one. + +Last, but not least, this layer may allow us to slowly get rid of protocol.js. +Command classes aren't Fronts, nor are they particularly connected to protocol.js. +If we make it so that all the Frontend code using Fronts uses Commands instead, we might more easily get away from protocol.js. + +If you want to create a new command, you can use a bash script to help your bootstrap all basic required files: +``` +$ ./create-command.sh command-file-name CommandName +``` +Where the first argument will be the name used for folder and files, using lower case and dash as separator. +And the second argument will be the class name in code, using camlcase. diff --git a/devtools/shared/commands/commands-factory.js b/devtools/shared/commands/commands-factory.js new file mode 100644 index 0000000000..257f50ce2f --- /dev/null +++ b/devtools/shared/commands/commands-factory.js @@ -0,0 +1,245 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + createCommandsDictionary, +} = require("resource://devtools/shared/commands/index.js"); +const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +loader.lazyRequireGetter( + this, + "DevToolsServer", + "resource://devtools/server/devtools-server.js", + true +); +// eslint-disable-next-line mozilla/reject-some-requires +loader.lazyRequireGetter( + this, + "DevToolsClient", + "resource://devtools/client/devtools-client.js", + true +); + +/** + * Functions for creating Commands for all debuggable contexts. + * + * All methods of this `CommandsFactory` object receive argument to describe to + * which particular context we want to debug. And all returns a new instance of `commands` object. + * Commands are implemented by modules defined in devtools/shared/commands. + */ +exports.CommandsFactory = { + /** + * Create commands for a given local tab. + * + * @param {Tab} tab: A local Firefox tab, running in this process. + * @param {Object} options + * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed, + * a new one will be created. + * @param {DevToolsClient} options.isWebExtension: An optional boolean to flag commands + * that are created for the WebExtension codebase. + * @returns {Object} Commands + */ + async forTab(tab, { client, isWebExtension } = {}) { + if (!client) { + client = await createLocalClient(); + } + + const descriptor = await client.mainRoot.getTab({ tab, isWebExtension }); + descriptor.doNotAttachThreadActor = isWebExtension; + const commands = await createCommandsDictionary(descriptor); + return commands; + }, + + /** + * Chrome mochitest don't have access to any "tab", + * so that the only way to attach to a fake tab is call RootFront.getTab + * without any argument. + */ + async forCurrentTabInChromeMochitest() { + const client = await createLocalClient(); + const descriptor = await client.mainRoot.getTab(); + const commands = await createCommandsDictionary(descriptor); + return commands; + }, + + /** + * Create commands for the main process. + * + * @param {Object} options + * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed, + * a new one will be created. + * @returns {Object} Commands + */ + async forMainProcess({ client } = {}) { + if (!client) { + client = await createLocalClient(); + } + + const descriptor = await client.mainRoot.getMainProcess(); + const commands = await createCommandsDictionary(descriptor); + return commands; + }, + + /** + * Create commands for a given remote tab. + * + * Note that it can also be used for local tab, but isLocalTab attribute + * on commands.descriptorFront will be false. + * + * @param {Number} browserId: Identify which tab we should create commands for. + * @param {Object} options + * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed, + * a new one will be created. + * @returns {Object} Commands + */ + async forRemoteTab(browserId, { client } = {}) { + if (!client) { + client = await createLocalClient(); + } + + const descriptor = await client.mainRoot.getTab({ browserId }); + const commands = await createCommandsDictionary(descriptor); + return commands; + }, + + /** + * Create commands for a given main process worker. + * + * @param {String} id: WorkerDebugger's id, which is a unique ID computed by the platform code. + * These ids are exposed via WorkerDescriptor's id attributes. + * WorkerDescriptors can be retrieved via MainFront.listAllWorkers()/listWorkers(). + * @param {Object} options + * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed, + * a new one will be created. + * @returns {Object} Commands + */ + async forWorker(id, { client } = {}) { + if (!client) { + client = await createLocalClient(); + } + + const descriptor = await client.mainRoot.getWorker(id); + const commands = await createCommandsDictionary(descriptor); + return commands; + }, + + /** + * Create commands for a Web Extension. + * + * @param {String} id The Web Extension ID to debug. + * @param {Object} options + * @param {DevToolsClient} options.client: An optional DevToolsClient. If none is passed, + * a new one will be created. + * @returns {Object} Commands + */ + async forAddon(id, { client } = {}) { + if (!client) { + client = await createLocalClient(); + } + + const descriptor = await client.mainRoot.getAddon({ id }); + const commands = await createCommandsDictionary(descriptor); + return commands; + }, + + /** + * This method will spawn a special `DevToolsClient` + * which is meant to debug the same Firefox instance + * and especially be able to debug chrome code. + * The chrome code typically runs in the system principal. + * This principal is a singleton which is shared among most Firefox internal codebase + * (JSM, privileged html documents, JS-XPCOM,...) + * In order to be able to debug these script we need to connect to a special DevToolsServer + * that runs in a dedicated and distinct system principal which is different from + * the one shared with the rest of Firefox frontend codebase. + */ + async spawnClientToDebugSystemPrincipal() { + // The Browser console ends up using the debugger in autocomplete. + // Because the debugger can't be running in the same compartment than its debuggee, + // we have to load the server in a dedicated Loader, flagged with + // `freshCompartment`, which will force it to be loaded in another compartment. + // We aren't using `invisibleToDebugger` in order to allow the Browser toolbox to + // debug the Browser console. This is fine as they will spawn distinct Loaders and + // so distinct `DevToolsServer` and actor modules. + const customLoader = new DevToolsLoader({ + freshCompartment: true, + }); + const { DevToolsServer: customDevToolsServer } = customLoader.require( + "resource://devtools/server/devtools-server.js" + ); + + customDevToolsServer.init(); + + // We want all the actors (root, browser and target-scoped) to be registered on the + // DevToolsServer. This is needed so the Browser Console can retrieve: + // - the console actors, which are target-scoped (See Bug 1416105) + // - the screenshotActor, which is browser-scoped (for the `:screenshot` command) + customDevToolsServer.registerAllActors(); + + customDevToolsServer.allowChromeProcess = true; + + const client = new DevToolsClient(customDevToolsServer.connectPipe()); + await client.connect(); + + return client; + }, + + /** + * One method to handle the whole setup sequence to connect to RDP backend for the Browser Console. + * + * This will instantiate a special DevTools module loader for the DevToolsServer. + * Then spawn a DevToolsClient to connect to it. + * Get a Main Process Descriptor from it. + * Finally spawn a commands object for this descriptor. + */ + async forBrowserConsole() { + // The Browser console ends up using the debugger in autocomplete. + // Because the debugger can't be running in the same compartment than its debuggee, + // we have to load the server in a dedicated Loader and so spawn a special client + const client = await this.spawnClientToDebugSystemPrincipal(); + + const descriptor = await client.mainRoot.getMainProcess(); + + descriptor.doNotAttachThreadActor = true; + + // Force fetching the first top level target right away. + await descriptor.getTarget(); + + const commands = await createCommandsDictionary(descriptor); + return commands; + }, +}; + +async function createLocalClient() { + // Make sure the DevTools server is started. + ensureDevToolsServerInitialized(); + + // Create the client and connect it to the local server. + const client = new DevToolsClient(DevToolsServer.connectPipe()); + await client.connect(); + + return client; +} +// Also expose this method for tests which would like to create a client +// without involving commands. This would typically be tests against the Watcher actor +// and requires to prevent having TargetCommand from running. +// Or tests which are covering RootFront or global actor's fronts. +exports.createLocalClientForTests = createLocalClient; + +function ensureDevToolsServerInitialized() { + // Since a remote protocol connection will be made, let's start the + // DevToolsServer here, once and for all tools. + DevToolsServer.init(); + + // Enable all the actors. We may not need all of them and registering + // only root and target might be enough + DevToolsServer.registerAllActors(); + + // Enable being able to get child process actors + // Same, this might not be useful + DevToolsServer.allowChromeProcess = true; +} diff --git a/devtools/shared/commands/create-command.sh b/devtools/shared/commands/create-command.sh new file mode 100755 index 0000000000..1f95f96df6 --- /dev/null +++ b/devtools/shared/commands/create-command.sh @@ -0,0 +1,129 @@ +#!/bin/bash + +# Script to easily create a new command, including: +# - a template for the main command file +# - test folder and test head.js file +# - a template for a first test +# - all necessary build manifests + +if [[ -z $1 || -z $2 ]]; then + echo "$0 expects two arguments:" + echo "$(basename $0) command-file-name CommandName" + echo " 1) The file name for the command, with '-' as separators between words" + echo " This will be the name of the folder" + echo " 2) The command name being caml cased" + echo " This will be used to craft the name of the JavaScript class" + exit +fi + +if [ -e $1 ]; then + echo "$1 already exists. Please use a new folder/command name." +fi + +CMD_FOLDER=$1 +CMD_FILE_NAME=$1-command.js +CMD_NAME=$2Command + +pushd `dirname $0` + +echo "Creating a new command called '$CMD_NAME' in $CMD_FOLDER" + +mkdir $CMD_FOLDER + +cat > $CMD_FOLDER/moz.build < $CMD_FOLDER/$CMD_FILE_NAME < $CMD_FOLDER/tests/browser.toml < $CMD_FOLDER/tests/head.js < $CMD_FOLDER/tests/browser_$1.js < { + const Constructor = require(Commands[name]); + const command = new Constructor({ + // Commands can use other commands + commands: dictionary, + + // The context to inspect identified by this descriptor + descriptorFront, + + // The front for the Watcher Actor, related to the given descriptor + // This is a key actor to watch for targets and resources and pull global actors running in the parent process + watcherFront, + + // From here, we could pass DevToolsClient, or any useful protocol classes... + // so that we abstract where and how to fetch all necessary interfaces + // and avoid having to know that you might pull the client via descriptorFront.client + }); + allInstantiatedCommands.add(command); + return command; + }); + } + + return dictionary; +} +exports.createCommandsDictionary = createCommandsDictionary; diff --git a/devtools/shared/commands/inspected-window/inspected-window-command.js b/devtools/shared/commands/inspected-window/inspected-window-command.js new file mode 100644 index 0000000000..0d15016ebd --- /dev/null +++ b/devtools/shared/commands/inspected-window/inspected-window-command.js @@ -0,0 +1,145 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + getAdHocFrontOrPrimitiveGrip, + // eslint-disable-next-line mozilla/reject-some-requires +} = require("resource://devtools/client/fronts/object.js"); + +/** + * For now, this class is mostly a wrapper around webExtInspectedWindow actor. + */ +class InspectedWindowCommand { + constructor({ commands }) { + this.commands = commands; + } + + /** + * Return a promise that resolves to the related target actor's front. + * The Web Extension inspected window actor. + * + * @return {Promise} + */ + getFront() { + return this.commands.targetCommand.targetFront.getFront( + "webExtensionInspectedWindow" + ); + } + + /** + * Evaluate the provided javascript code in a target window. + * + * @param {Object} webExtensionCallerInfo - The addonId and the url (the addon base url + * or the url of the actual caller filename and lineNumber) used to log useful + * debugging information in the produced error logs and eval stack trace. + * @param {String} expression - The expression to evaluate. + * @param {Object} options - An option object. Check the actor method definition to see + * what properties it can hold (minus the `consoleFront` property which is defined + * below). + * @param {WebConsoleFront} options.consoleFront - An optional webconsole front. When + * set, the result will be either a primitive, a LongStringFront or an + * ObjectFront, and the WebConsoleActor corresponding to the console front will + * be used to generate those, which is needed if we want to handle ObjectFronts + * on the client. + */ + async eval(webExtensionCallerInfo, expression, options = {}) { + const { consoleFront } = options; + + if (consoleFront) { + options.evalResultAsGrip = true; + options.toolboxConsoleActorID = consoleFront.actor; + delete options.consoleFront; + } + + const front = await this.getFront(); + const response = await front.eval( + webExtensionCallerInfo, + expression, + options + ); + + // If no consoleFront was provided, we can directly return the response. + if (!consoleFront) { + return response; + } + + if ( + !response.hasOwnProperty("exceptionInfo") && + !response.hasOwnProperty("valueGrip") + ) { + throw new Error( + "Response does not have `exceptionInfo` or `valueGrip` property" + ); + } + + if (response.exceptionInfo) { + console.error( + response.exceptionInfo.description, + ...(response.exceptionInfo.details || []) + ); + return response; + } + + // On the server, the valueGrip is created from the toolbox webconsole actor. + // If we want since the ObjectFront connection is inherited from the parent front, we + // need to set the console front as the parent front. + return getAdHocFrontOrPrimitiveGrip( + response.valueGrip, + consoleFront || this + ); + } + + /** + * Reload the target tab, optionally bypass cache, customize the userAgent and/or + * inject a script in targeted document or any of its sub-frame. + * + * @param {WebExtensionCallerInfo} callerInfo + * the addonId and the url (the addon base url or the url of the actual caller + * filename and lineNumber) used to log useful debugging information in the + * produced error logs and eval stack trace. + * @param {Object} options + * @param {boolean|undefined} options.ignoreCache + * Enable/disable the cache bypass headers. + * @param {string|undefined} options.injectedScript + * Evaluate the provided javascript code in the top level and every sub-frame + * created during the page reload, before any other script in the page has been + * executed. + * @param {string|undefined} options.userAgent + * Customize the userAgent during the page reload. + * @returns {Promise} A promise that resolves once the page is done loading when userAgent + * or injectedScript option are passed. If those options are not provided, the + * Promise will resolve after the reload was initiated. + */ + async reload(callerInfo, options = {}) { + if (this._reloadPending) { + return null; + } + + this._reloadPending = true; + + try { + // We always want to update the target configuration to set the user agent if one is + // passed, or to reset a potential existing override if userAgent isn't defined. + await this.commands.targetConfigurationCommand.updateConfiguration({ + customUserAgent: options.userAgent, + }); + + const front = await this.getFront(); + const result = await front.reload(callerInfo, options); + this._reloadPending = false; + + return result; + } catch (e) { + this._reloadPending = false; + console.error(e); + return Promise.reject({ + message: "An unexpected error occurred", + }); + } + } +} + +module.exports = InspectedWindowCommand; diff --git a/devtools/shared/commands/inspected-window/moz.build b/devtools/shared/commands/inspected-window/moz.build new file mode 100644 index 0000000000..69e72048f8 --- /dev/null +++ b/devtools/shared/commands/inspected-window/moz.build @@ -0,0 +1,10 @@ +# 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/. + +DevToolsModules( + "inspected-window-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/inspected-window/tests/browser.toml b/devtools/shared/commands/inspected-window/tests/browser.toml new file mode 100644 index 0000000000..4c700ff7fe --- /dev/null +++ b/devtools/shared/commands/inspected-window/tests/browser.toml @@ -0,0 +1,20 @@ +[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", + "inspectedwindow-reload-target.sjs", +] +prefs = [ + # restrictedDomains must be set as early as possible, before the first use of + # the preference. browser_webextension_inspected_window_access.js relies on + # this pref to be set. We cannot use "prefs =" at the individual file, because + # another test in this manifest may already have resulted in browser startup. + "extensions.webextensions.restrictedDomains=test2.example.com" +] + +["browser_webextension_inspected_window.js"] +["browser_webextension_inspected_window_access.js"] diff --git a/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window.js b/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window.js new file mode 100644 index 0000000000..bf2b752e4d --- /dev/null +++ b/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window.js @@ -0,0 +1,523 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_RELOAD_URL = `${URL_ROOT_SSL}/inspectedwindow-reload-target.sjs`; + +async function setup(pageUrl) { + // Disable bfcache for Fission for now. + // If Fission is disabled, the pref is no-op. + await SpecialPowers.pushPrefEnv({ + set: [["fission.bfcacheInParent", false]], + }); + + const extension = ExtensionTestUtils.loadExtension({ + background() { + // This is just an empty extension used to ensure that the caller extension uuid + // actually exists. + }, + }); + + await extension.startup(); + + const fakeExtCallerInfo = { + url: WebExtensionPolicy.getByID(extension.id).getURL( + "fake-caller-script.js" + ), + lineNumber: 1, + addonId: extension.id, + }; + + const tab = await addTab(pageUrl); + + const commands = await CommandsFactory.forTab(tab, { isWebExtension: true }); + await commands.targetCommand.startListening(); + + const webConsoleFront = await commands.targetCommand.targetFront.getFront( + "console" + ); + + return { + webConsoleFront, + commands, + extension, + fakeExtCallerInfo, + }; +} + +async function teardown({ commands, extension }) { + await commands.destroy(); + gBrowser.removeCurrentTab(); + await extension.unload(); +} + +function waitForNextTabNavigated(commands) { + const target = commands.targetCommand.targetFront; + return new Promise(resolve => { + target.on("tabNavigated", function tabNavigatedListener(pkt) { + if (pkt.state == "stop" && !pkt.isFrameSwitching) { + target.off("tabNavigated", tabNavigatedListener); + resolve(); + } + }); + }); +} + +// Script used as the injectedScript option in the inspectedWindow.reload tests. +function injectedScript() { + if (!window.pageScriptExecutedFirst) { + window.addEventListener( + "DOMContentLoaded", + function () { + if (document.querySelector("pre")) { + document.querySelector("pre").textContent = + "injected script executed first"; + } + }, + { once: true } + ); + } +} + +// Script evaluated in the target tab, to collect the results of injectedScript +// evaluation in the inspectedWindow.reload tests. +function collectEvalResults() { + const results = []; + let iframeDoc = document; + + while (iframeDoc) { + if (iframeDoc.querySelector("pre")) { + results.push(iframeDoc.querySelector("pre").textContent); + } + const iframe = iframeDoc.querySelector("iframe"); + iframeDoc = iframe ? iframe.contentDocument : null; + } + return JSON.stringify(results); +} + +add_task(async function test_successfull_inspectedWindowEval_result() { + const { commands, extension, fakeExtCallerInfo } = await setup(URL_ROOT_SSL); + + const result = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + "window.location", + {} + ); + + ok(result.value, "Got a result from inspectedWindow eval"); + is( + result.value.href, + URL_ROOT_SSL, + "Got the expected window.location.href property value" + ); + is( + result.value.protocol, + "https:", + "Got the expected window.location.protocol property value" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_successfull_inspectedWindowEval_resultAsGrip() { + const { commands, extension, fakeExtCallerInfo, webConsoleFront } = + await setup(URL_ROOT_SSL); + + let result = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + "window", + { + evalResultAsGrip: true, + toolboxConsoleActorID: webConsoleFront.actor, + } + ); + + ok(result.valueGrip, "Got a result from inspectedWindow eval"); + ok(result.valueGrip.actor, "Got a object actor as expected"); + is(result.valueGrip.type, "object", "Got a value grip of type object"); + is( + result.valueGrip.class, + "Window", + "Got a value grip which is instanceof Location" + ); + + // Test invalid evalResultAsGrip request. + result = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + "window", + { + evalResultAsGrip: true, + } + ); + + ok( + !result.value && !result.valueGrip, + "Got a null result from the invalid inspectedWindow eval call" + ); + ok( + result.exceptionInfo.isError, + "Got an API Error result from inspectedWindow eval" + ); + ok( + !result.exceptionInfo.isException, + "An error isException is false as expected" + ); + is( + result.exceptionInfo.code, + "E_PROTOCOLERROR", + "Got the expected 'code' property in the error result" + ); + is( + result.exceptionInfo.description, + "Inspector protocol error: %s - %s", + "Got the expected 'description' property in the error result" + ); + is( + result.exceptionInfo.details.length, + 2, + "The 'details' array property should contains 1 element" + ); + is( + result.exceptionInfo.details[0], + "Unexpected invalid sidebar panel expression request", + "Got the expected content in the error results's details" + ); + is( + result.exceptionInfo.details[1], + "missing toolboxConsoleActorID", + "Got the expected content in the error results's details" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_error_inspectedWindowEval_result() { + const { commands, extension, fakeExtCallerInfo } = await setup(URL_ROOT_SSL); + + const result = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + "window", + {} + ); + + ok(!result.value, "Got a null result from inspectedWindow eval"); + ok( + result.exceptionInfo.isError, + "Got an API Error result from inspectedWindow eval" + ); + ok( + !result.exceptionInfo.isException, + "An error isException is false as expected" + ); + is( + result.exceptionInfo.code, + "E_PROTOCOLERROR", + "Got the expected 'code' property in the error result" + ); + is( + result.exceptionInfo.description, + "Inspector protocol error: %s", + "Got the expected 'description' property in the error result" + ); + is( + result.exceptionInfo.details.length, + 1, + "The 'details' array property should contains 1 element" + ); + ok( + result.exceptionInfo.details[0].includes("cyclic object value"), + "Got the expected content in the error results's details" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_exception_inspectedWindowEval_result() { + const { commands, extension, fakeExtCallerInfo } = await setup(URL_ROOT_SSL); + + const result = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + "throw Error('fake eval error');", + {} + ); + + ok(result.exceptionInfo.isException, "Got an exception as expected"); + ok(!result.value, "Got an undefined eval value"); + ok(!result.exceptionInfo.isError, "An exception should not be isError=true"); + ok( + result.exceptionInfo.value.includes("Error: fake eval error"), + "Got the expected exception message" + ); + + const expectedCallerInfo = `called from ${fakeExtCallerInfo.url}:${fakeExtCallerInfo.lineNumber}`; + ok( + result.exceptionInfo.value.includes(expectedCallerInfo), + "Got the expected caller info in the exception message" + ); + + const expectedStack = `eval code:1:7`; + ok( + result.exceptionInfo.value.includes(expectedStack), + "Got the expected stack trace in the exception message" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_exception_inspectedWindowReload() { + const { commands, extension, fakeExtCallerInfo } = await setup( + `${TEST_RELOAD_URL}?test=cache` + ); + + // Test reload with bypassCache=false. + + const waitForNoBypassCacheReload = waitForNextTabNavigated(commands); + const reloadResult = await commands.inspectedWindowCommand.reload( + fakeExtCallerInfo, + { + ignoreCache: false, + } + ); + + ok( + !reloadResult, + "Got the expected undefined result from inspectedWindow reload" + ); + + await waitForNoBypassCacheReload; + + const noBypassCacheEval = await commands.scriptCommand.execute( + "document.body.textContent" + ); + + is( + noBypassCacheEval.result, + "empty cache headers", + "Got the expected result with reload forceBypassCache=false" + ); + + // Test reload with bypassCache=true. + + const waitForForceBypassCacheReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, { + ignoreCache: true, + }); + + await waitForForceBypassCacheReload; + + const forceBypassCacheEval = await commands.scriptCommand.execute( + "document.body.textContent" + ); + + is( + forceBypassCacheEval.result, + "no-cache:no-cache", + "Got the expected result with reload forceBypassCache=true" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_exception_inspectedWindowReload_customUserAgent() { + const { commands, extension, fakeExtCallerInfo } = await setup( + `${TEST_RELOAD_URL}?test=user-agent` + ); + + // Test reload with custom userAgent. + + const waitForCustomUserAgentReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, { + userAgent: "Customized User Agent", + }); + + await waitForCustomUserAgentReload; + + const customUserAgentEval = await commands.scriptCommand.execute( + "document.body.textContent" + ); + + is( + customUserAgentEval.result, + "Customized User Agent", + "Got the expected result on reload with a customized userAgent" + ); + + // Test reload with no custom userAgent. + + const waitForNoCustomUserAgentReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {}); + + await waitForNoCustomUserAgentReload; + + const noCustomUserAgentEval = await commands.scriptCommand.execute( + "document.body.textContent" + ); + + is( + noCustomUserAgentEval.result, + window.navigator.userAgent, + "Got the expected result with reload without a customized userAgent" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_exception_inspectedWindowReload_injectedScript() { + const { commands, extension, fakeExtCallerInfo } = await setup( + `${TEST_RELOAD_URL}?test=injected-script&frames=3` + ); + + // Test reload with an injectedScript. + + const waitForInjectedScriptReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, { + injectedScript: `new ${injectedScript}`, + }); + await waitForInjectedScriptReload; + + const injectedScriptEval = await commands.scriptCommand.execute( + `(${collectEvalResults})()` + ); + + const expectedResult = new Array(5).fill("injected script executed first"); + + SimpleTest.isDeeply( + JSON.parse(injectedScriptEval.result), + expectedResult, + "Got the expected result on reload with an injected script" + ); + + // Test reload without an injectedScript. + + const waitForNoInjectedScriptReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {}); + await waitForNoInjectedScriptReload; + + const noInjectedScriptEval = await commands.scriptCommand.execute( + `(${collectEvalResults})()` + ); + + const newExpectedResult = new Array(5).fill("injected script NOT executed"); + + SimpleTest.isDeeply( + JSON.parse(noInjectedScriptEval.result), + newExpectedResult, + "Got the expected result on reload with no injected script" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_exception_inspectedWindowReload_multiple_calls() { + const { commands, extension, fakeExtCallerInfo } = await setup( + `${TEST_RELOAD_URL}?test=user-agent` + ); + + // Test reload with custom userAgent three times (and then + // check that only the first one has affected the page reload. + + const waitForCustomUserAgentReload = waitForNextTabNavigated(commands); + + commands.inspectedWindowCommand.reload(fakeExtCallerInfo, { + userAgent: "Customized User Agent 1", + }); + commands.inspectedWindowCommand.reload(fakeExtCallerInfo, { + userAgent: "Customized User Agent 2", + }); + + await waitForCustomUserAgentReload; + + const customUserAgentEval = await commands.scriptCommand.execute( + "document.body.textContent" + ); + + is( + customUserAgentEval.result, + "Customized User Agent 1", + "Got the expected result on reload with a customized userAgent" + ); + + // Test reload with no custom userAgent. + + const waitForNoCustomUserAgentReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {}); + + await waitForNoCustomUserAgentReload; + + const noCustomUserAgentEval = await commands.scriptCommand.execute( + "document.body.textContent" + ); + + is( + noCustomUserAgentEval.result, + window.navigator.userAgent, + "Got the expected result with reload without a customized userAgent" + ); + + await teardown({ commands, extension }); +}); + +add_task(async function test_exception_inspectedWindowReload_stopped() { + const { commands, extension, fakeExtCallerInfo } = await setup( + `${TEST_RELOAD_URL}?test=injected-script&frames=3` + ); + + // Test reload on a page that calls window.stop() immediately during the page loading + + const waitForPageLoad = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + "window.location += '&stop=windowStop'" + ); + + info("Load a webpage that calls 'window.stop()' while is still loading"); + await waitForPageLoad; + + info("Starting a reload with an injectedScript"); + const waitForInjectedScriptReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, { + injectedScript: `new ${injectedScript}`, + }); + await waitForInjectedScriptReload; + + const injectedScriptEval = await commands.scriptCommand.execute( + `(${collectEvalResults})()` + ); + + // The page should have stopped during the reload and only one injected script + // is expected. + const expectedResult = new Array(1).fill("injected script executed first"); + + SimpleTest.isDeeply( + JSON.parse(injectedScriptEval.result), + expectedResult, + "The injected script has been executed on the 'stopped' page reload" + ); + + // Reload again with no options. + + info("Reload the tab again without any reload options"); + const waitForNoInjectedScriptReload = waitForNextTabNavigated(commands); + await commands.inspectedWindowCommand.reload(fakeExtCallerInfo, {}); + await waitForNoInjectedScriptReload; + + const noInjectedScriptEval = await commands.scriptCommand.execute( + `(${collectEvalResults})()` + ); + + // The page should have stopped during the reload and no injected script should + // have been executed during this second reload (or it would mean that the previous + // customized reload was still pending and has wrongly affected the second reload) + const newExpectedResult = new Array(1).fill("injected script NOT executed"); + + SimpleTest.isDeeply( + JSON.parse(noInjectedScriptEval.result), + newExpectedResult, + "No injectedScript should have been evaluated during the second reload" + ); + + await teardown({ commands, extension }); +}); + +// TODO: check eval with $0 binding once implemented (Bug 1300590) diff --git a/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window_access.js b/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window_access.js new file mode 100644 index 0000000000..3b32bb0aaa --- /dev/null +++ b/devtools/shared/commands/inspected-window/tests/browser_webextension_inspected_window_access.js @@ -0,0 +1,315 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function run_inspectedWindow_eval({ tab, codeToEval, extension }) { + const fakeExtCallerInfo = { + url: `moz-extension://${extension.uuid}/another/fake-caller-script.js`, + lineNumber: 1, + addonId: extension.id, + }; + const commands = await CommandsFactory.forTab(tab, { isWebExtension: true }); + await commands.targetCommand.startListening(); + const result = await commands.inspectedWindowCommand.eval( + fakeExtCallerInfo, + codeToEval, + {} + ); + await commands.destroy(); + return result; +} + +async function openAboutBlankTabWithExtensionOrigin(extension) { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + `moz-extension://${extension.uuid}/manifest.json` + ); + const loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await ContentTask.spawn(tab.linkedBrowser, null, () => { + // about:blank inherits the principal when opened from content. + content.wrappedJSObject.location.assign("about:blank"); + }); + await loaded; + // Sanity checks: + is(tab.linkedBrowser.currentURI.spec, "about:blank", "expected tab"); + is( + tab.linkedBrowser.contentPrincipal.originNoSuffix, + `moz-extension://${extension.uuid}`, + "about:blank should be at the extension origin" + ); + return tab; +} + +async function checkEvalResult({ + extension, + description, + url, + createTab = () => BrowserTestUtils.openNewForegroundTab(gBrowser, url), + expectedResult, +}) { + const tab = await createTab(); + is(tab.linkedBrowser.currentURI.spec, url, "Sanity check: tab URL"); + const result = await run_inspectedWindow_eval({ + tab, + codeToEval: "'code executed at ' + location.href", + extension, + }); + BrowserTestUtils.removeTab(tab); + SimpleTest.isDeeply( + result, + expectedResult, + `eval result for devtools.inspectedWindow.eval at ${url} (${description})` + ); +} + +async function checkEvalAllowed({ extension, description, url, createTab }) { + info(`checkEvalAllowed: ${description} (at URL: ${url})`); + await checkEvalResult({ + extension, + description, + url, + createTab, + expectedResult: { value: `code executed at ${url}` }, + }); +} +async function checkEvalDenied({ extension, description, url, createTab }) { + info(`checkEvalDenied: ${description} (at URL: ${url})`); + await checkEvalResult({ + extension, + description, + url, + createTab, + expectedResult: { + exceptionInfo: { + isError: true, + code: "E_PROTOCOLERROR", + details: [ + "This extension is not allowed on the current inspected window origin", + ], + description: "Inspector protocol error: %s", + }, + }, + }); +} + +add_task(async function test_eval_at_http() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + const httpUrl = "http://example.com/"; + + // When running with --use-http3-server, http:-URLs cannot be loaded. + try { + await fetch(httpUrl); + } catch { + info("Skipping test_eval_at_http because http:-URL cannot be loaded"); + return; + } + + const extension = ExtensionTestUtils.loadExtension({}); + await extension.startup(); + + await checkEvalAllowed({ + extension, + description: "http:-URL", + url: httpUrl, + }); + await extension.unload(); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_eval_at_https() { + const extension = ExtensionTestUtils.loadExtension({}); + await extension.startup(); + + const privilegedExtension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + }); + await privilegedExtension.startup(); + + await checkEvalAllowed({ + extension, + description: "https:-URL", + url: "https://example.com/", + }); + + await checkEvalDenied({ + extension, + description: "a restricted domain", + // Domain in extensions.webextensions.restrictedDomains by browser.toml. + url: "https://test2.example.com/", + }); + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.quarantinedDomains.list", "example.com"]], + }); + + await checkEvalDenied({ + extension, + description: "a quarantined domain", + url: "https://example.com/", + }); + + await checkEvalAllowed({ + extension: privilegedExtension, + description: "a quarantined domain", + url: "https://example.com/", + }); + + await SpecialPowers.popPrefEnv(); + + await extension.unload(); + await privilegedExtension.unload(); +}); + +add_task(async function test_eval_at_sandboxed_page() { + const extension = ExtensionTestUtils.loadExtension({}); + await extension.startup(); + + await checkEvalAllowed({ + extension, + description: "page with CSP sandbox", + url: "https://example.com/document-builder.sjs?headers=Content-Security-Policy:sandbox&html=x", + }); + await checkEvalDenied({ + extension, + description: "restricted domain with CSP sandbox", + url: "https://test2.example.com/document-builder.sjs?headers=Content-Security-Policy:sandbox&html=x", + }); + + await extension.unload(); +}); + +add_task(async function test_eval_at_own_extension_origin_allowed() { + const extension = ExtensionTestUtils.loadExtension({ + background() { + // eslint-disable-next-line no-undef + browser.test.sendMessage( + "blob_url", + URL.createObjectURL(new Blob(["blob: here", { type: "text/html" }])) + ); + }, + files: { + "mozext.html": `moz-extension: here`, + }, + }); + await extension.startup(); + const blobUrl = await extension.awaitMessage("blob_url"); + + await checkEvalAllowed({ + extension, + description: "moz-extension:-URL from own extension", + url: `moz-extension://${extension.uuid}/mozext.html`, + }); + await checkEvalAllowed({ + extension, + description: "blob:-URL from own extension", + url: blobUrl, + }); + await checkEvalAllowed({ + extension, + description: "about:blank with origin from own extension", + url: "about:blank", + createTab: () => openAboutBlankTabWithExtensionOrigin(extension), + }); + + await extension.unload(); +}); + +add_task(async function test_eval_at_other_extension_denied() { + // The extension for which we simulate devtools_page, chosen as caller of + // devtools.inspectedWindow.eval API calls. + const extension = ExtensionTestUtils.loadExtension({}); + await extension.startup(); + + // The other extension, that |extension| should not be able to access: + const otherExt = ExtensionTestUtils.loadExtension({ + background() { + // eslint-disable-next-line no-undef + browser.test.sendMessage( + "blob_url", + URL.createObjectURL(new Blob(["blob: here", { type: "text/html" }])) + ); + }, + files: { + "mozext.html": `moz-extension: here`, + }, + }); + await otherExt.startup(); + const otherExtBlobUrl = await otherExt.awaitMessage("blob_url"); + + await checkEvalDenied({ + extension, + description: "moz-extension:-URL from another extension", + url: `moz-extension://${otherExt.uuid}/mozext.html`, + }); + await checkEvalDenied({ + extension, + description: "blob:-URL from another extension", + url: otherExtBlobUrl, + }); + await checkEvalDenied({ + extension, + description: "about:blank with origin from another extension", + url: "about:blank", + createTab: () => openAboutBlankTabWithExtensionOrigin(otherExt), + }); + + await otherExt.unload(); + await extension.unload(); +}); + +add_task(async function test_eval_at_about() { + const extension = ExtensionTestUtils.loadExtension({}); + await extension.startup(); + await checkEvalAllowed({ + extension, + description: "about:blank (null principal)", + url: "about:blank", + }); + await checkEvalDenied({ + extension, + description: "about:addons (system principal)", + url: "about:addons", + }); + await checkEvalDenied({ + extension, + description: "about:robots (about page)", + url: "about:robots", + }); + await extension.unload(); +}); + +add_task(async function test_eval_at_file() { + // FYI: There is also an equivalent test case with a full end-to-end test at: + // browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_file.js + + const extension = ExtensionTestUtils.loadExtension({}); + await extension.startup(); + + // A dummy file URL that can be loaded in a tab. + const fileUrl = + "file://" + + getTestFilePath("browser_webextension_inspected_window_access.js"); + + // checkEvalAllowed test helper cannot be used, because the file:-URL may + // redirect elsewhere, so the comparison with the full URL fails. + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, fileUrl); + const result = await run_inspectedWindow_eval({ + tab, + codeToEval: "'code executed at ' + location.protocol", + extension, + }); + BrowserTestUtils.removeTab(tab); + SimpleTest.isDeeply( + result, + { value: "code executed at file:" }, + `eval result for devtools.inspectedWindow.eval at ${fileUrl}` + ); + + await extension.unload(); +}); diff --git a/devtools/shared/commands/inspected-window/tests/head.js b/devtools/shared/commands/inspected-window/tests/head.js new file mode 100644 index 0000000000..ce65b3d827 --- /dev/null +++ b/devtools/shared/commands/inspected-window/tests/head.js @@ -0,0 +1,12 @@ +/* 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 +); diff --git a/devtools/shared/commands/inspected-window/tests/inspectedwindow-reload-target.sjs b/devtools/shared/commands/inspected-window/tests/inspectedwindow-reload-target.sjs new file mode 100644 index 0000000000..4e737ad207 --- /dev/null +++ b/devtools/shared/commands/inspected-window/tests/inspectedwindow-reload-target.sjs @@ -0,0 +1,87 @@ +"use strict"; + +function handleRequest(request, response) { + const params = new URLSearchParams(request.queryString); + + switch (params.get("test")) { + case "cache": + handleCacheTestRequest(request, response); + break; + + case "user-agent": + handleUserAgentTestRequest(request, response); + break; + + case "injected-script": + handleInjectedScriptTestRequest(request, response, params); + break; + } +} + +function handleCacheTestRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("pragma") && request.hasHeader("cache-control")) { + response.write( + `${request.getHeader("pragma")}:${request.getHeader("cache-control")}` + ); + } else { + response.write("empty cache headers"); + } +} + +function handleUserAgentTestRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("user-agent")) { + response.write(request.getHeader("user-agent")); + } else { + response.write("no user agent header"); + } +} + +function handleInjectedScriptTestRequest(request, response, params) { + response.setHeader("Content-Type", "text/html; charset=UTF-8", false); + + const frames = parseInt(params.get("frames"), 10); + let content = ""; + + if (frames > 0) { + // Output an iframe in seamless mode, so that there is an higher chance that in case + // of test failures we get a screenshot where the nested iframes are all visible. + content = ``; + } else { + // Output an about:srcdoc frame to be sure that inspectedWindow.eval is able to + // evaluate js code into it. + const srcdoc = ` +
injected script NOT executed
+ + `; + content = ``; + } + + if (params.get("stop") == "windowStop") { + content = "" + content; + } + + response.write(` + + + + + + +

IFRAME ${frames}

+
injected script NOT executed
+ + ${content} + + + `); +} diff --git a/devtools/shared/commands/inspector/inspector-command.js b/devtools/shared/commands/inspector/inspector-command.js new file mode 100644 index 0000000000..a8c4edd6c1 --- /dev/null +++ b/devtools/shared/commands/inspector/inspector-command.js @@ -0,0 +1,483 @@ +/* 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"; + +loader.lazyRequireGetter( + this, + "getTargetBrowsers", + "resource://devtools/shared/compatibility/compatibility-user-settings.js", + true +); +loader.lazyRequireGetter( + this, + "TARGET_BROWSER_PREF", + "resource://devtools/shared/compatibility/constants.js", + true +); + +class InspectorCommand { + constructor({ commands }) { + this.commands = commands; + } + + #cssDeclarationBlockIssuesQueuedDomRulesDeclarations = []; + #cssDeclarationBlockIssuesPendingTimeoutPromise; + #cssDeclarationBlockIssuesTargetBrowsersPromise; + + /** + * Return the list of all current target's inspector fronts + * + * @return {Promise>} + */ + async getAllInspectorFronts() { + return this.commands.targetCommand.getAllFronts( + [this.commands.targetCommand.TYPES.FRAME], + "inspector" + ); + } + + /** + * Search the document for the given string and return all the results. + * + * @param {Object} walkerFront + * @param {String} query + * The string to search for. + * @param {Object} options + * {Boolean} options.reverse - search backwards + * @returns {Array} The list of search results + */ + async walkerSearch(walkerFront, query, options = {}) { + const result = await walkerFront.search(query, options); + return result.list.items(); + } + + /** + * Incrementally search the top-level document and sub frames for a given string. + * Only one result is sent back at a time. Calling the + * method again with the same query will send the next result. + * If a new query which does not match the current one all is reset and new search + * is kicked off. + * + * @param {String} query + * The string / selector searched for + * @param {Object} options + * {Boolean} reverse - determines if the search is done backwards + * @returns {Object} res + * {String} res.type + * {String} res.query - The string / selector searched for + * {Object} res.node - the current node + * {Number} res.resultsIndex - The index of the current node + * {Number} res.resultsLength - The total number of results found. + */ + async findNextNode(query, { reverse } = {}) { + const inspectors = await this.getAllInspectorFronts(); + const nodes = await Promise.all( + inspectors.map(({ walker }) => + this.walkerSearch(walker, query, { reverse }) + ) + ); + const results = nodes.flat(); + + // If the search query changes + if (this._searchQuery !== query) { + this._searchQuery = query; + this._currentIndex = -1; + } + + if (!results.length) { + return null; + } + + this._currentIndex = reverse + ? this._currentIndex - 1 + : this._currentIndex + 1; + + if (this._currentIndex >= results.length) { + this._currentIndex = 0; + } + if (this._currentIndex < 0) { + this._currentIndex = results.length - 1; + } + + return { + node: results[this._currentIndex], + resultsIndex: this._currentIndex, + resultsLength: results.length, + }; + } + + /** + * Returns a list of matching results for CSS selector autocompletion. + * + * @param {String} query + * The selector query being completed + * @param {String} firstPart + * The exact token being completed out of the query + * @param {String} state + * One of "pseudo", "id", "tag", "class", "null" + * @return {Array} suggestions + * The list of suggested CSS selectors + */ + async getSuggestionsForQuery(query, firstPart, state) { + // Get all inspectors where we want suggestions from. + const inspectors = await this.getAllInspectorFronts(); + + const mergedSuggestions = []; + // Get all of the suggestions. + await Promise.all( + inspectors.map(async ({ walker }) => { + const { suggestions } = await walker.getSuggestionsForQuery( + query, + firstPart, + state + ); + for (const [suggestion, count, type] of suggestions) { + // Merge any already existing suggestion with the new one, by incrementing the count + // which is the second element of the array. + const existing = mergedSuggestions.find( + ([s, , t]) => s == suggestion && t == type + ); + if (existing) { + existing[1] += count; + } else { + mergedSuggestions.push([suggestion, count, type]); + } + } + }) + ); + + // Descending sort the list by count, i.e. second element of the arrays + return sortSuggestions(mergedSuggestions); + } + + /** + * Find a nodeFront from an array of selectors. The last item of the array is the selector + * for the element in its owner document, and the previous items are selectors to iframes + * that lead to the frame where the searched node lives in. + * + * For example, with the following markup + * + * + * + * + * If you want to retrieve the `

` nodeFront, `selectors` would be: + * [ + * "#level-1", + * "#level-2", + * "h1", + * ] + * + * @param {Array} selectors + * An array of CSS selectors to find the target accessible object. + * Several selectors can be needed if the element is nested in frames + * and not directly in the root document. + * @param {Integer} timeoutInMs + * The maximum number of ms the function should run (defaults to 5000). + * If it exceeds this, the returned promise will resolve with `null`. + * @return {Promise} a promise that resolves when the node front is found + * for selection using inspector tools. It resolves with the deepest frame document + * that could be retrieved when the "final" nodeFront couldn't be found in the page. + * It resolves with `null` when the function runs for more than timeoutInMs. + */ + async findNodeFrontFromSelectors(nodeSelectors, timeoutInMs = 5000) { + if ( + !nodeSelectors || + !Array.isArray(nodeSelectors) || + nodeSelectors.length === 0 + ) { + console.warn( + "findNodeFrontFromSelectors expect a non-empty array but got", + nodeSelectors + ); + return null; + } + + const { walker } = await this.commands.targetCommand.targetFront.getFront( + "inspector" + ); + const querySelectors = async nodeFront => { + const selector = nodeSelectors.shift(); + if (!selector) { + return nodeFront; + } + nodeFront = await nodeFront.walkerFront.querySelector( + nodeFront, + selector + ); + // It's possible the containing iframe isn't available by the time + // walkerFront.querySelector is called, which causes the re-selected node to be + // unavailable. There also isn't a way for us to know when all iframes on the page + // have been created after a reload. Because of this, we should should bail here. + if (!nodeFront) { + return null; + } + + if (nodeSelectors.length) { + if (!nodeFront.isShadowHost) { + await this.#waitForFrameLoad(nodeFront); + } + + const { nodes } = await walker.children(nodeFront); + + // If there are remaining selectors to process, they will target a document or a + // document-fragment under the current node. Whether the element is a frame or + // a web component, it can only contain one document/document-fragment, so just + // select the first one available. + nodeFront = nodes.find(node => { + const { nodeType } = node; + return ( + nodeType === Node.DOCUMENT_FRAGMENT_NODE || + nodeType === Node.DOCUMENT_NODE + ); + }); + + // The iframe selector might have matched an element which is not an + // iframe in the new page (or an iframe with no document?). In this + // case, bail out and fallback to the root body element. + if (!nodeFront) { + return null; + } + } + const childrenNodeFront = await querySelectors(nodeFront); + return childrenNodeFront || nodeFront; + }; + const rootNodeFront = await walker.getRootNode(); + + // Since this is only used for re-setting a selection after a page reloads, we can + // put a timeout, in case there's an iframe that would take too much time to load, + // and prevent the markup view to be populated. + const onTimeout = new Promise(res => setTimeout(res, timeoutInMs)).then( + () => null + ); + const onQuerySelectors = querySelectors(rootNodeFront); + return Promise.race([onTimeout, onQuerySelectors]); + } + + /** + * Wait for the given NodeFront child document to be loaded. + * + * @param {NodeFront} A nodeFront representing a frame + */ + async #waitForFrameLoad(nodeFront) { + const domLoadingPromises = []; + + // if the flag isn't true, we don't know for sure if the iframe will be remote + // or not; when the nodeFront was created, the iframe might still have been loading + // and in such case, its associated window can be an initial document. + // Luckily, once EFT is enabled everywhere we can remove this call and only wait + // for the associated target. + if (!nodeFront.useChildTargetToFetchChildren) { + domLoadingPromises.push(nodeFront.waitForFrameLoad()); + } + + const { onResource: onDomInteractiveResource } = + await this.commands.resourceCommand.waitForNextResource( + this.commands.resourceCommand.TYPES.DOCUMENT_EVENT, + { + // We might be in a case where the children document is already loaded (i.e. we + // would already have received the dom-interactive resource), so it's important + // to _not_ ignore existing resource. + predicate: resource => + resource.name == "dom-interactive" && + resource.targetFront !== nodeFront.targetFront && + resource.targetFront.browsingContextID == + nodeFront.browsingContextID, + } + ); + const newTargetResolveValue = Symbol(); + domLoadingPromises.push( + onDomInteractiveResource.then(() => newTargetResolveValue) + ); + + // Here we wait for any promise to resolve first. `waitForFrameLoad` might throw + // (if the iframe does end up being remote), so we don't want to use `Promise.race`. + const loadResult = await Promise.any(domLoadingPromises); + + // The Node may have `useChildTargetToFetchChildren` set to false because the + // child document was still loading when fetching its form. But it may happen that + // the Node ends up being a remote iframe. + // When this happen we will try to call `waitForFrameLoad` which will throw, but + // we will be notified about the new target. + // This is the special edge case we are trying to handle here. + // We want WalkerFront.children to consider this as an iframe with a dedicated target. + if (loadResult == newTargetResolveValue) { + nodeFront._form.useChildTargetToFetchChildren = true; + } + } + + /** + * Get the full array of selectors from the topmost document, going through + * iframes. + * For example, given the following markup: + * + * + * + * + * + * + * + * If this function is called with the NodeFront for the h1#sub-document-title element, + * it will return something like: ["body > iframe", "#sub-document-title"] + * + * @param {NodeFront} nodeFront: The nodefront to get the selectors for + * @returns {Promise>} A promise that resolves with an array of selectors (strings) + */ + async getNodeFrontSelectorsFromTopDocument(nodeFront) { + const selectors = []; + + let currentNode = nodeFront; + while (currentNode) { + // Get the selector for the node inside its document + const selector = await currentNode.getUniqueSelector(); + selectors.unshift(selector); + + // Retrieve the node's document/shadowRoot nodeFront so we can get its parent + // (so if we're in an iframe, we'll get the + ` + ); + const tab = await addTab( + "https://example.org/document-builder.sjs?html=" + html, + { waitForLoad: false } + ); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + info("Check that it returns null when no params are passed"); + let nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors(); + is( + nodeFront, + null, + `findNodeFrontFromSelectors returns null when no param is passed` + ); + + info("Check that it returns null when a string is passed"); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors( + "body main" + ); + is( + nodeFront, + null, + `findNodeFrontFromSelectors returns null when passed a string` + ); + + info("Check it returns null when an empty array is passed"); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([]); + is( + nodeFront, + null, + `findNodeFrontFromSelectors returns null when passed an empty array` + ); + + info("Check that passing a selector for a non-matching element returns null"); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([ + "h1", + ]); + is( + nodeFront, + null, + "findNodeFrontFromSelectors returns null as there's no

element in the page" + ); + + info("Check passing a selector for an element in the top document"); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([ + "button", + ]); + is( + nodeFront.typeName, + "domnode", + "findNodeFrontFromSelectors returns a nodeFront" + ); + is( + nodeFront.displayName, + "button", + "findNodeFrontFromSelectors returned the appropriate nodeFront" + ); + + info("Check passing a selector for an element in a same origin iframe"); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([ + "#iframe-org", + "#in-iframe", + ]); + is( + nodeFront.displayName, + "h2", + "findNodeFrontFromSelectors returned the appropriate nodeFront" + ); + + info("Check passing a selector for an element in a cross origin iframe"); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([ + "#iframe-com", + "h3", + ]); + is( + nodeFront.displayName, + "h3", + "findNodeFrontFromSelectors returned the appropriate nodeFront" + ); + + info( + "Check passing a selector for an non-existing element in an existing iframe" + ); + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors([ + "iframe", + "#non-existant-id", + ]); + is( + nodeFront.displayName, + "#document", + "findNodeFrontFromSelectors returned the last matching iframe document if the children selector isn't found" + ); + is( + nodeFront.parentNode().displayName, + "iframe", + "findNodeFrontFromSelectors returned the last matching iframe document if the children selector isn't found" + ); + + info("Check that timeout does work"); + // Reload the page so we'll have the iframe loading (for 3s) and we can check that + // putting a smaller timeout will result in the function returning null. + // we need to wait until it's fully processed to avoid pending promises. + const onNewTargetProcessed = commands.targetCommand.once( + "processed-available-target" + ); + await reloadBrowser({ waitForLoad: false }); + await onNewTargetProcessed; + nodeFront = await commands.inspectorCommand.findNodeFrontFromSelectors( + ["#iframe-org", "#in-iframe"], + // timeout in ms (smaller than 3s) + 100 + ); + is( + nodeFront, + null, + "findNodeFrontFromSelectors timed out and returned null, as expected" + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js new file mode 100644 index 0000000000..3e5abcddd0 --- /dev/null +++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_getNodeFrontSelectorsFromTopDocument.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing getNodeFrontSelectorsFromTopDocument + +add_task(async () => { + const html = ` + + + + Test + + +
+ hello + world +
+
+ +
+
+ + +
content
+
+ + + `; + + const tab = await addTab("data:text/html," + encodeURIComponent(html)); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const walker = ( + await commands.targetCommand.targetFront.getFront("inspector") + ).walker; + + const checkSelectors = (...args) => + checkSelectorsFromTopDocumentForNode(commands, ...args); + + let node = await getNodeFrontInFrames(["meta"], { walker }); + await checkSelectors( + node, + ["head > meta:nth-child(1)"], + "Got expected selectors for the top-level meta node" + ); + + node = await getNodeFrontInFrames(["body"], { walker }); + await checkSelectors( + node, + ["body"], + "Got expected selectors for the top-level body node" + ); + + node = await getNodeFrontInFrames(["header > span"], { walker }); + await checkSelectors( + node, + ["body > header:nth-child(1) > span:nth-child(1)"], + "Got expected selectors for the top-level span node" + ); + + node = await getNodeFrontInFrames(["iframe"], { walker }); + await checkSelectors( + node, + ["body > main:nth-child(2) > iframe:nth-child(1)"], + "Got expected selectors for the iframe node" + ); + + node = await getNodeFrontInFrames(["iframe", "body"], { walker }); + await checkSelectors( + node, + ["body > main:nth-child(2) > iframe:nth-child(1)", "body"], + "Got expected selectors for the iframe body node" + ); + + const hostFront = await getNodeFront("test-component", { walker }); + const { nodes } = await walker.children(hostFront); + const shadowRoot = nodes.find(hostNode => hostNode.isShadowRoot); + node = await walker.querySelector(shadowRoot, ".slot-class"); + + await checkSelectors( + node, + ["body > test-component:nth-child(4)", ".slot-class"], + "Got expected selectors for the shadow dom node" + ); + + await commands.destroy(); +}); + +async function checkSelectorsFromTopDocumentForNode( + commands, + nodeFront, + expectedSelectors, + assertionText +) { + const selectors = + await commands.inspectorCommand.getNodeFrontSelectorsFromTopDocument( + nodeFront + ); + is( + JSON.stringify(selectors), + JSON.stringify(expectedSelectors), + assertionText + ); +} diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js new file mode 100644 index 0000000000..e7b765b1d0 --- /dev/null +++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_getSuggestionsForQuery.js @@ -0,0 +1,124 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async () => { + // Build a test page with a remote iframe, using two distinct origins .com and .org + const iframeHtml = encodeURIComponent(`
`); + const html = encodeURIComponent( + `
+
+
+ ` + ); + const tab = await addTab( + "https://example.com/document-builder.sjs?html=" + html + ); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + info( + "Suggestions for 'di' with tag search, will match the two
elements in top document and the one in the iframe" + ); + await assertSuggestion( + commands, + { query: "", firstPart: "di", state: "tag" }, + [ + { + suggestion: "div", + count: 3, // Matches the two
in the top document and the one in the iframe + type: "tag", + }, + ] + ); + + info( + "Suggestions for 'ifram' with id search, will only match the
within the iframe" + ); + await assertSuggestion( + commands, + { query: "", firstPart: "ifram", state: "id" }, + [ + { + suggestion: "#iframe", + count: 1, + type: "id", + }, + ] + ); + + info( + "Suggestions for 'fo' with tag search, will match the class of the top
element" + ); + await assertSuggestion( + commands, + { query: "", firstPart: "fo", state: "tag" }, + [ + { + suggestion: ".foo", + count: 1, + type: "class", + }, + ] + ); + + info( + "Suggestions for classes, based on div elements, will match the two classes of top
element" + ); + await assertSuggestion( + commands, + { query: "div", firstPart: "", state: "class" }, + [ + { + suggestion: ".bar", + count: 1, + type: "class", + }, + { + suggestion: ".foo", + count: 1, + type: "class", + }, + ] + ); + + info("Suggestion for non-existent tag names will return no suggestion"); + await assertSuggestion( + commands, + { query: "", firstPart: "marquee", state: "tag" }, + [] + ); + + await commands.destroy(); +}); + +async function assertSuggestion( + commands, + { query, firstPart, state }, + expectedSuggestions +) { + const suggestions = await commands.inspectorCommand.getSuggestionsForQuery( + query, + firstPart, + state + ); + is( + suggestions.length, + expectedSuggestions.length, + "Got the expected number of suggestions" + ); + for (let i = 0; i < expectedSuggestions.length; i++) { + info(` ## Asserting suggestion #${i}:`); + const expectedSuggestion = expectedSuggestions[i]; + const [suggestion, count, type] = suggestions[i]; + is( + suggestion, + expectedSuggestion.suggestion, + "The suggested string is valid" + ); + is(count, expectedSuggestion.count, "The number of matches is valid"); + is(type, expectedSuggestion.type, "The type of match is valid"); + } +} diff --git a/devtools/shared/commands/inspector/tests/browser_inspector_command_search.js b/devtools/shared/commands/inspector/tests/browser_inspector_command_search.js new file mode 100644 index 0000000000..d7d25d3ce6 --- /dev/null +++ b/devtools/shared/commands/inspector/tests/browser_inspector_command_search.js @@ -0,0 +1,98 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing basic inspector search + +add_task(async () => { + const html = `
+
+

This is the paragraph node down in the tree

+
+
+
+ +
`; + + const tab = await addTab("data:text/html," + encodeURIComponent(html)); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + info("Search using text"); + await searchAndAssert( + commands, + { query: "paragraph", reverse: false }, + { resultsLength: 1, resultsIndex: 0 } + ); + + info("Search using class selector"); + info(" > Get first result "); + await searchAndAssert( + commands, + { query: ".child", reverse: false }, + { resultsLength: 2, resultsIndex: 0 } + ); + + info(" > Get next result "); + await searchAndAssert( + commands, + { query: ".child", reverse: false }, + { resultsLength: 2, resultsIndex: 1 } + ); + + info("Search using el selector with reverse option"); + info(" > Get first result "); + await searchAndAssert( + commands, + { query: "div", reverse: true }, + { resultsLength: 6, resultsIndex: 5 } + ); + + info(" > Get next result "); + await searchAndAssert( + commands, + { query: "div", reverse: true }, + { resultsLength: 6, resultsIndex: 4 } + ); + + info("Search for foo in remote frame"); + await searchAndAssert( + commands, + { query: ".frame-child", reverse: false }, + { resultsLength: 1, resultsIndex: 0 } + ); + + await commands.destroy(); +}); +/** + * Does an inspector search to find the next node and assert the results + * + * @param {Object} commands + * @param {Object} options + * options.query - search query + * options.reverse - search in reverse + * @param {Object} expected + * Holds the expected values + */ +async function searchAndAssert(commands, { query, reverse }, expected) { + const response = await commands.inspectorCommand.findNextNode(query, { + reverse, + }); + + is( + response.resultsLength, + expected.resultsLength, + "Got the expected no of results" + ); + + is( + response.resultsIndex, + expected.resultsIndex, + "Got the expected currently selected node index" + ); +} diff --git a/devtools/shared/commands/inspector/tests/head.js b/devtools/shared/commands/inspector/tests/head.js new file mode 100644 index 0000000000..73d9798446 --- /dev/null +++ b/devtools/shared/commands/inspector/tests/head.js @@ -0,0 +1,14 @@ +/* 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"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); diff --git a/devtools/shared/commands/moz.build b/devtools/shared/commands/moz.build new file mode 100644 index 0000000000..b006ad8dbd --- /dev/null +++ b/devtools/shared/commands/moz.build @@ -0,0 +1,22 @@ +# 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/. + +DIRS += [ + "inspected-window", + "inspector", + "network", + "object", + "resource", + "root-resource", + "script", + "target", + "target-configuration", + "thread-configuration", + "tracer", +] + +DevToolsModules( + "commands-factory.js", + "index.js", +) diff --git a/devtools/shared/commands/network/moz.build b/devtools/shared/commands/network/moz.build new file mode 100644 index 0000000000..9d74abdfa2 --- /dev/null +++ b/devtools/shared/commands/network/moz.build @@ -0,0 +1,10 @@ +# 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/. + +DevToolsModules( + "network-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/network/network-command.js b/devtools/shared/commands/network/network-command.js new file mode 100644 index 0000000000..44cdf4e759 --- /dev/null +++ b/devtools/shared/commands/network/network-command.js @@ -0,0 +1,96 @@ +/* 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"; + +class NetworkCommand { + /** + * This class helps listen, inspect and control network requests. + * + * @param {DescriptorFront} descriptorFront + * The context to inspect identified by this descriptor. + * @param {WatcherFront} watcherFront + * If available, a reference to the related Watcher Front. + * @param {Object} commands + * The commands object with all interfaces defined from devtools/shared/commands/ + */ + constructor({ descriptorFront, watcherFront, commands }) { + this.commands = commands; + this.descriptorFront = descriptorFront; + this.watcherFront = watcherFront; + } + + /** + * Send a HTTP request data payload + * + * @param {object} data data payload would like to sent to backend + */ + async sendHTTPRequest(data) { + // By default use the top-level target, but we might at some point + // allow using another target. + const networkContentFront = + await this.commands.targetCommand.targetFront.getFront("networkContent"); + const { channelId } = await networkContentFront.sendHTTPRequest(data); + return { channelId }; + } + + /* + * Get the list of blocked URL filters. + * + * A URL filter is a RegExp string so that one filter can match many URLs. + * It can be an absolute URL to match only one precise request: + * http://mozilla.org/index.html + * Or just a string which would match all URL containing this string: + * mozilla + * Or a RegExp to match various types of URLs: + * http://*mozilla.org/*.css + * + * @return {Array} + * List of all currently blocked URL filters. + */ + async getBlockedUrls() { + const networkParentFront = await this.watcherFront.getNetworkParentActor(); + return networkParentFront.getBlockedUrls(); + } + + /** + * Updates the list of blocked URL filters. + * + * @param {Array} urls + * An array of URL filter strings. + * See getBlockedUrls for definition of URL filters. + */ + async setBlockedUrls(urls) { + const networkParentFront = await this.watcherFront.getNetworkParentActor(); + return networkParentFront.setBlockedUrls(urls); + } + + /** + * Block only one additional URL filter + * + * @param {String} url + * URL filter to block. + * See getBlockedUrls for definition of URL filters. + */ + async blockRequestForUrl(url) { + const networkParentFront = await this.watcherFront.getNetworkParentActor(); + return networkParentFront.blockRequest({ url }); + } + + /** + * Stop blocking only one specific URL filter + * + * @param {String} url + * URL filter to unblock. + * See getBlockedUrls for definition of URL filters. + */ + async unblockRequestForUrl(url) { + const networkParentFront = await this.watcherFront.getNetworkParentActor(); + return networkParentFront.unblockRequest({ url }); + } + + destroy() {} +} + +module.exports = NetworkCommand; diff --git a/devtools/shared/commands/network/tests/browser.toml b/devtools/shared/commands/network/tests/browser.toml new file mode 100644 index 0000000000..60b5949bb0 --- /dev/null +++ b/devtools/shared/commands/network/tests/browser.toml @@ -0,0 +1,13 @@ +[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", +] + +["browser_network_command_request_blocking.js"] + +["browser_network_command_sendHTTPRequest.js"] diff --git a/devtools/shared/commands/network/tests/browser_network_command_request_blocking.js b/devtools/shared/commands/network/tests/browser_network_command_request_blocking.js new file mode 100644 index 0000000000..dd1167fa9b --- /dev/null +++ b/devtools/shared/commands/network/tests/browser_network_command_request_blocking.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the NetworkCommand API around request blocking + +add_task(async function () { + info("Test NetworkCommand request blocking"); + const tab = await addTab("data:text/html,foo"); + const commands = await CommandsFactory.forTab(tab); + const networkCommand = commands.networkCommand; + const resourceCommand = commands.resourceCommand; + + // Usage of request blocking APIs requires to listen to NETWORK_EVENT. + await resourceCommand.watchResources([resourceCommand.TYPES.NETWORK_EVENT], { + onAvailable: () => {}, + }); + + let blockedUrls = await networkCommand.getBlockedUrls(); + Assert.deepEqual( + blockedUrls, + [], + "The list of blocked URLs is originaly empty" + ); + + await networkCommand.blockRequestForUrl("https://foo.com"); + blockedUrls = await networkCommand.getBlockedUrls(); + Assert.deepEqual( + blockedUrls, + ["https://foo.com"], + "The freshly added blocked URL is reported as blocked" + ); + + // We pass "url filters" which can be only part of a URL string + await networkCommand.blockRequestForUrl("bar"); + blockedUrls = await networkCommand.getBlockedUrls(); + Assert.deepEqual( + blockedUrls, + ["https://foo.com", "bar"], + "The second blocked URL is also reported as blocked" + ); + + await networkCommand.setBlockedUrls(["https://mozilla.org"]); + blockedUrls = await networkCommand.getBlockedUrls(); + Assert.deepEqual( + blockedUrls, + ["https://mozilla.org"], + "setBlockedUrls replace the whole list of blocked URLs" + ); + + await networkCommand.unblockRequestForUrl("https://mozilla.org"); + blockedUrls = await networkCommand.getBlockedUrls(); + Assert.deepEqual( + blockedUrls, + [], + "The unblocked URL disappear from the list of blocked URLs" + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/network/tests/browser_network_command_sendHTTPRequest.js b/devtools/shared/commands/network/tests/browser_network_command_sendHTTPRequest.js new file mode 100644 index 0000000000..1d84a8a668 --- /dev/null +++ b/devtools/shared/commands/network/tests/browser_network_command_sendHTTPRequest.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the NetworkCommand's sendHTTPRequest + +add_task(async function () { + info("Test NetworkCommand.sendHTTPRequest"); + const tab = await addTab("data:text/html,foo"); + const commands = await CommandsFactory.forTab(tab); + + // We have to ensure TargetCommand is initialized to have access to the top level target + // from NetworkCommand.sendHTTPRequest + await commands.targetCommand.startListening(); + + const { networkCommand } = commands; + + const httpServer = createTestHTTPServer(); + const onRequest = new Promise(resolve => { + httpServer.registerPathHandler( + "/http-request.html", + (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("Response body"); + resolve(request); + } + ); + }); + const url = `http://localhost:${httpServer.identity.primaryPort}/http-request.html`; + + info("Call NetworkCommand.sendHTTPRequest"); + const { resourceCommand } = commands; + const { onResource } = await resourceCommand.waitForNextResource( + resourceCommand.TYPES.NETWORK_EVENT + ); + const { channelId } = await networkCommand.sendHTTPRequest({ + url, + method: "POST", + headers: [{ name: "Request", value: "Header" }], + body: "Hello", + cause: { + loadingDocumentUri: "https://example.com", + stacktraceAvailable: true, + type: "xhr", + }, + }); + ok(channelId, "Received a channel id in response"); + const resource = await onResource; + is( + resource.resourceId, + channelId, + "NETWORK_EVENT resource channelId is the same as the one returned by sendHTTPRequest" + ); + + const request = await onRequest; + is(request.method, "POST", "Request method is correct"); + is(request.getHeader("Request"), "Header", "The custom header was passed"); + is(fetchRequestBody(request), "Hello", "The request POST's body is correct"); + + await commands.destroy(); +}); + +const BinaryInputStream = Components.Constructor( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +function fetchRequestBody(request) { + let body = ""; + const bodyStream = new BinaryInputStream(request.bodyInputStream); + let avail = 0; + while ((avail = bodyStream.available()) > 0) { + body += String.fromCharCode.apply(String, bodyStream.readByteArray(avail)); + } + return body; +} diff --git a/devtools/shared/commands/network/tests/head.js b/devtools/shared/commands/network/tests/head.js new file mode 100644 index 0000000000..ce65b3d827 --- /dev/null +++ b/devtools/shared/commands/network/tests/head.js @@ -0,0 +1,12 @@ +/* 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 +); diff --git a/devtools/shared/commands/object/moz.build b/devtools/shared/commands/object/moz.build new file mode 100644 index 0000000000..151750907c --- /dev/null +++ b/devtools/shared/commands/object/moz.build @@ -0,0 +1,10 @@ +# 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/. + +DevToolsModules( + "object-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/object/object-command.js b/devtools/shared/commands/object/object-command.js new file mode 100644 index 0000000000..0396b6167a --- /dev/null +++ b/devtools/shared/commands/object/object-command.js @@ -0,0 +1,63 @@ +/* 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"; + +/** + * The ObjectCommand helps inspecting and managing lifecycle + * of all inspected JavaScript objects. + */ +class ObjectCommand { + constructor({ commands, descriptorFront, watcherFront }) { + this.#commands = commands; + } + #commands = null; + + /** + * Release a set of object actors all at once. + * + * @param {Array} frontsToRelease + * List of fronts for the object to release. + */ + async releaseObjects(frontsToRelease) { + // @backward-compat { version 123 } A new Objects Manager front has a new "releaseActors" method. + // Only supportsReleaseActors=true codepath can be kept once 123 is the release channel. + const { supportsReleaseActors } = this.#commands.client.mainRoot.traits; + + // First group all object fronts per target + const actorsPerTarget = new Map(); + const promises = []; + for (const frontToRelease of frontsToRelease) { + const { targetFront } = frontToRelease; + // If the front is already destroyed, its target front will be nullified. + if (!targetFront) { + continue; + } + + let actorIDsToRemove = actorsPerTarget.get(targetFront); + if (!actorIDsToRemove) { + actorIDsToRemove = []; + actorsPerTarget.set(targetFront, actorIDsToRemove); + } + if (supportsReleaseActors) { + actorIDsToRemove.push(frontToRelease.actorID); + frontToRelease.destroy(); + } else { + promises.push(frontToRelease.release()); + } + } + + if (supportsReleaseActors) { + // Then release all fronts by bulk per target + for (const [targetFront, actorIDs] of actorsPerTarget) { + const objectsManagerFront = await targetFront.getFront("objects-manager"); + promises.push(objectsManagerFront.releaseObjects(actorIDs)); + } + } + + await Promise.all(promises); + } +} + +module.exports = ObjectCommand; diff --git a/devtools/shared/commands/object/tests/browser.toml b/devtools/shared/commands/object/tests/browser.toml new file mode 100644 index 0000000000..4f1dbe830e --- /dev/null +++ b/devtools/shared/commands/object/tests/browser.toml @@ -0,0 +1,9 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "head.js", +] + +["browser_object.js"] diff --git a/devtools/shared/commands/object/tests/browser_object.js b/devtools/shared/commands/object/tests/browser_object.js new file mode 100644 index 0000000000..9f6d5132d3 --- /dev/null +++ b/devtools/shared/commands/object/tests/browser_object.js @@ -0,0 +1,125 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ObjectCommand + +add_task(async function testObjectRelease() { + const tab = await addTab("data:text/html;charset=utf-8,Test page"); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const { objectCommand } = commands; + + const evaluationResponse = await commands.scriptCommand.execute( + "window.foo" + ); + + // Execute a second time so that the WebConsoleActor set this._lastConsoleInputEvaluation to another value + // and so we prevent freeing `window.foo` + await commands.scriptCommand.execute(""); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + is(content.wrappedJSObject.foo.bar, 42); + const weakRef = Cu.getWeakReference(content.wrappedJSObject.foo); + + // Hold off the weak reference on SpecialPowsers so that it can be accessed in the next SpecialPowers.spawn + SpecialPowers.weakRef = weakRef; + + // Nullify this variable so that it should be freed + // unless the DevTools inspection still hold it in memory + content.wrappedJSObject.foo = null; + + Cu.forceGC(); + Cu.forceCC(); + + ok(SpecialPowers.weakRef.get(), "The 'foo' object can't be freed because of DevTools keeping a reference on it"); + }); + + info("Release the server side actors which are keeping the object in memory"); + const objectFront = evaluationResponse.result; + await commands.objectCommand.releaseObjects([objectFront]); + + ok(objectFront.isDestroyed(), "The passed object front has been destroyed"); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => { + Cu.forceGC(); + Cu.forceCC(); + return !SpecialPowers.weakRef.get(); + }, "Wait for JS object to be freed", 500); + + ok(!SpecialPowers.weakRef.get(), "The 'foo' object has been freed"); + }); + + await commands.destroy(); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testMultiTargetObjectRelease() { + // This test fails with EFT disabled + if (!isEveryFrameTargetEnabled()) { + return; + } + + const tab = await addTab(`data:text/html;charset=utf-8,Test page`; + +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: "", + 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: "", + inherits: true, + initialValue: "10px", + fromJS: true, + targetFront: topLevelTarget, + }); + assertResource(availableResources[4], { + name: "--css-a", + syntax: "", + inherits: true, + initialValue: "gold", + fromJS: false, + targetFront: iframeTarget, + }); + assertResource(availableResources[5], { + name: "--js-a", + syntax: "", + 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: ""; + 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: "", + 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: "#", + 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: "#", + 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: "#", + 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: "#", + 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: ""; + 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: ""; + 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: "", + 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}Document Events`; + const tab = await addTab(url); + + const listener = new ResourceListener(); + const { commands } = await initResourceCommand(tab); + + info( + "Check whether the document events are fired correctly even when the document was already loaded" + ); + const onLoadingAtInit = listener.once("dom-loading"); + const onInteractiveAtInit = listener.once("dom-interactive"); + const onCompleteAtInit = listener.once("dom-complete"); + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: parameters => listener.dispatch(parameters), + } + ); + await assertPromises( + commands, + // targetBeforeNavigation is only used when there is a will-navigate and a navigate, but there is none here + null, + // As we started watching on an already loaded document, and no navigation happened since we called watchResources, + // we don't have any will-navigate event + null, + onLoadingAtInit, + onInteractiveAtInit, + onCompleteAtInit + ); + ok( + true, + "Document events are fired even when the document was already loaded" + ); + let domLoadingResource = await onLoadingAtInit; + + is( + domLoadingResource.url, + url, + `resource ${domLoadingResource.name} has expected url` + ); + is( + domLoadingResource.title, + undefined, + `resource ${domLoadingResource.name} does not have a title property` + ); + + let domInteractiveResource = await onInteractiveAtInit; + is( + domInteractiveResource.url, + url, + `resource ${domInteractiveResource.name} has expected url` + ); + is( + domInteractiveResource.title, + title, + `resource ${domInteractiveResource.name} has expected title` + ); + let domCompleteResource = await onCompleteAtInit; + is( + domCompleteResource.url, + undefined, + `resource ${domCompleteResource.name} does not have a url property` + ); + is( + domCompleteResource.title, + undefined, + `resource ${domCompleteResource.name} does not have a title property` + ); + + info("Check whether the document events are fired correctly when reloading"); + const onWillNavigate = listener.once("will-navigate"); + const onLoadingAtReloaded = listener.once("dom-loading"); + const onInteractiveAtReloaded = listener.once("dom-interactive"); + const onCompleteAtReloaded = listener.once("dom-complete"); + const targetBeforeNavigation = commands.targetCommand.targetFront; + gBrowser.reloadTab(tab); + await assertPromises( + commands, + targetBeforeNavigation, + onWillNavigate, + onLoadingAtReloaded, + onInteractiveAtReloaded, + onCompleteAtReloaded + ); + ok(true, "Document events are fired after reloading"); + + domLoadingResource = await onLoadingAtReloaded; + is( + domLoadingResource.url, + url, + `resource ${domLoadingResource.name} has expected url after reloading` + ); + is( + domLoadingResource.title, + undefined, + `resource ${domLoadingResource.name} does not have a title property after reloading` + ); + + domInteractiveResource = await onInteractiveAtInit; + is( + domInteractiveResource.url, + url, + `resource ${domInteractiveResource.name} has url property after reloading` + ); + is( + domInteractiveResource.title, + title, + `resource ${domInteractiveResource.name} has expected title after reloading` + ); + domCompleteResource = await onCompleteAtInit; + is( + domCompleteResource.url, + undefined, + `resource ${domCompleteResource.name} does not have a url property after reloading` + ); + is( + domCompleteResource.title, + undefined, + `resource ${domCompleteResource.name} does not have a title property after reloading` + ); + + await commands.destroy(); +} + +async function testDocumentEventResourcesWithIgnoreExistingResources() { + info("Test ignoreExistingResources option for DOCUMENT_EVENT"); + + const tab = await addTab("data:text/html,Document Events"); + + const { commands } = await initResourceCommand(tab); + + info("Check whether the existing document events will not be fired"); + const documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => documentEvents.push(...resources), + ignoreExistingResources: true, + } + ); + is(documentEvents.length, 0, "Existing document events are not fired"); + + info("Check whether the future document events are fired"); + const targetBeforeNavigation = commands.targetCommand.targetFront; + gBrowser.reloadTab(tab); + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length === 4); + assertEvents({ commands, targetBeforeNavigation, documentEvents }); + + await commands.destroy(); +} + +async function testIframeNavigation() { + info("Test iframe navigations for DOCUMENT_EVENT"); + + const tab = await addTab( + 'https://example.com/document-builder.sjs?html=' + ); + const secondPageUrl = "https://example.org/document-builder.sjs?html=org"; + + const { commands } = await initResourceCommand(tab); + + let documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => documentEvents.push(...resources), + } + ); + 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,firstfirst page"; + const secondLocation = "data:text/html,secondsecond page"; + const tab = await addTab(firstLocation); + const onLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + secondLocation + ); + await onLoaded; + + const { commands } = await initResourceCommand(tab); + + const documentEvents = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: resources => { + documentEvents.push(...resources); + }, + ignoreExistingResources: true, + } + ); + // Wait for some time for extra safety + await wait(250); + is(documentEvents.length, 0, "Existing document events are not fired"); + + info("Navigate back to the first page"); + const onSwitched = commands.targetCommand.once("switched-target"); + const targetBeforeNavigation = commands.targetCommand.targetFront; + gBrowser.goBack(); + + // 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=titleNetnet"; + 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," + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check that all DOCUMENT_EVENTS are fired for the already loaded page"); + const documentEvents = []; + await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { + onAvailable: resources => documentEvents.push(...resources), + }); + is(documentEvents.length, 3, "Existing document events are fired"); + + const domComplete = documentEvents[2]; + is(domComplete.name, "dom-complete", "the last resource is the dom-complete"); + is( + domComplete.hasNativeConsoleAPI, + false, + "the console object is reported to be overloaded" + ); + + targetCommand.destroy(); + await client.close(); +} + +async function testDomCompleteWithWindowStop() { + info("Test dom-complete with a page calling window.stop()"); + + const tab = await addTab("data:text/html,foo"); + + const { commands, client, resourceCommand, targetCommand } = + await initResourceCommand(tab); + + info("Check that all DOCUMENT_EVENTS are fired for the already loaded page"); + let documentEvents = []; + await resourceCommand.watchResources([resourceCommand.TYPES.DOCUMENT_EVENT], { + onAvailable: resources => documentEvents.push(...resources), + }); + is(documentEvents.length, 3, "Existing document events are fired"); + documentEvents = []; + + const html = ` + + stopped page + + + Page content that shouldn't be displayed +`; + const secondLocation = "data:text/html," + encodeURIComponent(html); + const targetBeforeNavigation = commands.targetCommand.targetFront; + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + secondLocation + ); + info( + "Wait for will-navigate, dom-loading, dom-interactive and dom-complete events" + ); + await waitFor(() => documentEvents.length === 4); + + assertEvents({ commands, targetBeforeNavigation, documentEvents }); + + targetCommand.destroy(); + await client.close(); +} + +async function 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(`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,Not private"; +const PRIVATE_TEST_URI = `data:text/html;charset=utf8,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 = + ""; + 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`; + const iframeUrl = + "https://example.org/document-builder.sjs?html=" + + encodeURIComponent(iframeHtml); + const html = `top-document`; + 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=

Test reflow resources

" + ); + + 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=

remote iframe

"; + 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,
"); + + 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,
` + ); + 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(`
`); + +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 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 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(` + + +Test stylesheet + + +

Hello

+ `); + }); + + let resourceUrlCalls = 0; + // The /test/ URL should be called: + // - once by the content page to load the + // - once by the content page to load the "; + +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(""); + +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," + ); + + 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,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 @@ + + + + + Test document for console + + + +

Test document for console

+ + + + + 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 @@ + + + + + Test fission iframe document + + + +

remote iframe

+ + + 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 @@ + + + + + Test fission document + + + + + 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 @@ + + + + + + + Empty page (No network requests) + + + 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 @@ + + + + + Test fission document + + + +

Test fission iframe

+ + + + 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 @@ + + + + + Test fission document + + + + +

Test fission iframe

+ + + + 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 @@ + + + + + Test fission iframe document + + + +

remote iframe

+ + 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 @@ + + + + + Test fission iframe document + + + + +

remote iframe

+ + 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 + */ +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 @@ + + + + + + + + Test for network events + + +

Test for network events

+ + 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 @@ + + + + + + + + Test for network events + + +

Test for network events

+ + + + + + + + + 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 @@ + + + + + + + + + SSE Inspection Test Page + + +

SSE Inspection Test Page

+ + + + + 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 @@ + + + + + + + + + SSE Inspection Test Page in iframe + + +

SSE Inspection Test Page in Iframe

+ + + 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 @@ + + + + + Test style document + + + + + + + + + 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 @@ + + + + + Test style iframe document + + + + + + + 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 @@ + + + + + StyleSheetsActor iframe test + + + +

A test page with nested iframes

+ + + + diff --git a/devtools/shared/commands/resource/tests/test_image.png b/devtools/shared/commands/resource/tests/test_image.png new file mode 100644 index 0000000000..769c636340 Binary files /dev/null and b/devtools/shared/commands/resource/tests/test_image.png differ 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 @@ + + + + + + Websocket Inspection Test Page + + +

Websocket Inspection Test Page

+ + + + + 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 @@ + + + + + + Websocket Inspection Test Page + + +

Websocket Inspection Test Page

+ + + 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() {} diff --git a/devtools/shared/commands/resource/transformers/console-messages.js b/devtools/shared/commands/resource/transformers/console-messages.js new file mode 100644 index 0000000000..9c8ca51f04 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/console-messages.js @@ -0,0 +1,23 @@ +/* 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-disable-next-line mozilla/reject-some-requires +loader.lazyRequireGetter( + this, + "getAdHocFrontOrPrimitiveGrip", + "resource://devtools/client/fronts/object.js", + true +); + +module.exports = function ({ resource, targetFront }) { + if (Array.isArray(resource.message.arguments)) { + // We might need to create fronts for each of the message arguments. + resource.message.arguments = resource.message.arguments.map(arg => + getAdHocFrontOrPrimitiveGrip(arg, targetFront) + ); + } + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/error-messages.js b/devtools/shared/commands/resource/transformers/error-messages.js new file mode 100644 index 0000000000..2b71f5b7ca --- /dev/null +++ b/devtools/shared/commands/resource/transformers/error-messages.js @@ -0,0 +1,31 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// eslint-disable-next-line mozilla/reject-some-requires +loader.lazyRequireGetter( + this, + "getAdHocFrontOrPrimitiveGrip", + "resource://devtools/client/fronts/object.js", + true +); + +module.exports = function ({ resource, targetFront }) { + if (resource?.pageError?.errorMessage) { + resource.pageError.errorMessage = getAdHocFrontOrPrimitiveGrip( + resource.pageError.errorMessage, + targetFront + ); + } + + if (resource?.pageError?.exception) { + resource.pageError.exception = getAdHocFrontOrPrimitiveGrip( + resource.pageError.exception, + targetFront + ); + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/moz.build b/devtools/shared/commands/resource/transformers/moz.build new file mode 100644 index 0000000000..5b0b94853a --- /dev/null +++ b/devtools/shared/commands/resource/transformers/moz.build @@ -0,0 +1,16 @@ +# 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/. + +DevToolsModules( + "console-messages.js", + "error-messages.js", + "network-events.js", + "storage-cache.js", + "storage-cookie.js", + "storage-extension.js", + "storage-indexed-db.js", + "storage-local-storage.js", + "storage-session-storage.js", + "thread-states.js", +) diff --git a/devtools/shared/commands/resource/transformers/network-events.js b/devtools/shared/commands/resource/transformers/network-events.js new file mode 100644 index 0000000000..d7f757d706 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/network-events.js @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + getUrlDetails, + // eslint-disable-next-line mozilla/reject-some-requires +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); + +module.exports = function ({ resource }) { + resource.urlDetails = getUrlDetails(resource.url); + resource.startedMs = Date.parse(resource.startedDateTime); + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-cache.js b/devtools/shared/commands/resource/transformers/storage-cache.js new file mode 100644 index 0000000000..245d892041 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-cache.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { CACHE_STORAGE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + // instantiate front for local storage + resource = types.getType("Cache").read(resource, targetFront); + resource.resourceType = CACHE_STORAGE; + resource.resourceKey = "Cache"; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-cookie.js b/devtools/shared/commands/resource/transformers/storage-cookie.js new file mode 100644 index 0000000000..23e221672b --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-cookie.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { COOKIE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + const { innerWindowId } = resource; + + // it's safe to instantiate the front now, so we do it. + resource = types.getType("cookies").read(resource, targetFront); + resource.resourceType = COOKIE; + resource.resourceId = `${COOKIE}-${targetFront.browsingContextID}`; + resource.resourceKey = "cookies"; + resource.innerWindowId = innerWindowId; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-extension.js b/devtools/shared/commands/resource/transformers/storage-extension.js new file mode 100644 index 0000000000..3e40bdd6d0 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-extension.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { EXTENSION_STORAGE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + const { innerWindowId } = resource; + + // it's safe to instantiate the front now, so we do it. + resource = types.getType("extensionStorage").read(resource, targetFront); + resource.resourceType = EXTENSION_STORAGE; + resource.resourceId = `${EXTENSION_STORAGE}-${targetFront.browsingContextID}`; + resource.resourceKey = "extensionStorage"; + resource.innerWindowId = innerWindowId; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-indexed-db.js b/devtools/shared/commands/resource/transformers/storage-indexed-db.js new file mode 100644 index 0000000000..8021719070 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-indexed-db.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { INDEXED_DB }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + const { innerWindowId } = resource; + + // it's safe to instantiate the front now, so we do it. + resource = types.getType("indexedDB").read(resource, targetFront); + resource.resourceType = INDEXED_DB; + resource.resourceId = `${INDEXED_DB}-${targetFront.browsingContextID}`; + resource.resourceKey = "indexedDB"; + resource.innerWindowId = innerWindowId; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-local-storage.js b/devtools/shared/commands/resource/transformers/storage-local-storage.js new file mode 100644 index 0000000000..13488723f3 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-local-storage.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { LOCAL_STORAGE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + // instantiate front for local storage + resource = types.getType("localStorage").read(resource, targetFront); + resource.resourceType = LOCAL_STORAGE; + resource.resourceKey = "localStorage"; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/storage-session-storage.js b/devtools/shared/commands/resource/transformers/storage-session-storage.js new file mode 100644 index 0000000000..ab9f1361c8 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/storage-session-storage.js @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + TYPES: { SESSION_STORAGE }, +} = require("resource://devtools/shared/commands/resource/resource-command.js"); + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + if (!(resource instanceof Front) && watcherFront) { + // instantiate front for session storage + resource = types.getType("sessionStorage").read(resource, targetFront); + resource.resourceType = SESSION_STORAGE; + resource.resourceKey = "sessionStorage"; + } + + return resource; +}; diff --git a/devtools/shared/commands/resource/transformers/thread-states.js b/devtools/shared/commands/resource/transformers/thread-states.js new file mode 100644 index 0000000000..1564585b36 --- /dev/null +++ b/devtools/shared/commands/resource/transformers/thread-states.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { Front, types } = require("resource://devtools/shared/protocol.js"); + +module.exports = function ({ resource, watcherFront, targetFront }) { + // only "paused" have a frame attribute, and legacy listeners are already passing a FrameFront + if (resource.frame && !(resource.frame instanceof Front)) { + // Use ThreadFront as parent as debugger's commands.js expects FrameFront to be children + // of the ThreadFront. + resource.frame = types + .getType("frame") + .read(resource.frame, targetFront.threadFront); + } + + // If we are using server side request (i.e. watcherFront is defined) + // Fake paused and resumed events as the thread front depends on them. + // We can't emit "EventEmitter" events, as ThreadFront uses `Front.before` + // to listen for paused and resumed. ("before" is part of protocol.js Front and not part of EventEmitter) + if (watcherFront) { + if (resource.state == "paused") { + targetFront.threadFront._beforePaused(resource); + } else if (resource.state == "resumed") { + targetFront.threadFront._beforeResumed(resource); + } + } + + return resource; +}; diff --git a/devtools/shared/commands/root-resource/moz.build b/devtools/shared/commands/root-resource/moz.build new file mode 100644 index 0000000000..2bf7204d1f --- /dev/null +++ b/devtools/shared/commands/root-resource/moz.build @@ -0,0 +1,7 @@ +# 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/. + +DevToolsModules( + "root-resource-command.js", +) diff --git a/devtools/shared/commands/root-resource/root-resource-command.js b/devtools/shared/commands/root-resource/root-resource-command.js new file mode 100644 index 0000000000..1071d1bcb1 --- /dev/null +++ b/devtools/shared/commands/root-resource/root-resource-command.js @@ -0,0 +1,348 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { throttle } = require("resource://devtools/shared/throttle.js"); + +class RootResourceCommand { + /** + * This class helps retrieving existing and listening to "root" resources. + * + * This is a fork of ResourceCommand, but specific to context-less + * resources which can be listened to right away when connecting to the RDP server. + * + * The main difference in term of implementation is that: + * - we receive a root front as constructor argument (instead of `commands` object) + * - we only listen for RDP events on the Root actor (instead of watcher and target actors) + * - there is no legacy listener support + * - there is no resource transformers + * - there is a lot of logic around targets that is removed here. + * + * See ResourceCommand for comments and jsdoc. + * + * TODO Bug 1758530 - Investigate sharing code with ResourceCommand instead of forking. + * + * @param object commands + * The commands object with all interfaces defined from devtools/shared/commands/ + * @param object rootFront + * Front for the Root actor. + */ + constructor({ commands, rootFront }) { + this.rootFront = rootFront ? rootFront : commands.client.mainRoot; + + this._onResourceAvailable = this._onResourceAvailable.bind(this); + this._onResourceDestroyed = this._onResourceDestroyed.bind(this); + + this._watchers = []; + + this._pendingWatchers = new Set(); + + this._cache = []; + this._listenedResources = new Set(); + + this._processingExistingResources = new Set(); + + this._notifyWatchers = this._notifyWatchers.bind(this); + this._throttledNotifyWatchers = throttle(this._notifyWatchers, 100); + } + + getAllResources(resourceType) { + return this._cache.filter(r => r.resourceType === resourceType); + } + + getResourceById(resourceType, resourceId) { + return this._cache.find( + r => r.resourceType === resourceType && r.resourceId === resourceId + ); + } + + async watchResources(resources, options) { + const { + onAvailable, + onUpdated, + onDestroyed, + ignoreExistingResources = false, + } = options; + + if (typeof onAvailable !== "function") { + throw new Error( + "RootResourceCommand.watchResources expects an onAvailable function as argument" + ); + } + + for (const type of resources) { + if (!this._isValidResourceType(type)) { + throw new Error( + `RootResourceCommand.watchResources invoked with an unknown type: "${type}"` + ); + } + } + + const pendingWatcher = { + resources, + onAvailable, + }; + this._pendingWatchers.add(pendingWatcher); + + if (!this._listenerRegistered) { + this._listenerRegistered = true; + this.rootFront.on("resource-available-form", this._onResourceAvailable); + this.rootFront.on("resource-destroyed-form", this._onResourceDestroyed); + } + + const promises = []; + for (const resource of resources) { + promises.push(this._startListening(resource)); + } + await Promise.all(promises); + + this._notifyWatchers(); + + this._pendingWatchers.delete(pendingWatcher); + + const watchedResources = pendingWatcher.resources; + + if (!watchedResources.length) { + return; + } + + this._watchers.push({ + resources: watchedResources, + onAvailable, + onUpdated, + onDestroyed, + pendingEvents: [], + }); + + if (!ignoreExistingResources) { + await this._forwardExistingResources(watchedResources, onAvailable); + } + } + + unwatchResources(resources, options) { + const { onAvailable } = options; + + if (typeof onAvailable !== "function") { + throw new Error( + "RootResourceCommand.unwatchResources expects an onAvailable function as argument" + ); + } + + for (const type of resources) { + if (!this._isValidResourceType(type)) { + throw new Error( + `RootResourceCommand.unwatchResources invoked with an unknown type: "${type}"` + ); + } + } + + const allWatchers = [...this._watchers, ...this._pendingWatchers]; + for (const watcherEntry of allWatchers) { + if (watcherEntry.onAvailable == onAvailable) { + watcherEntry.resources = watcherEntry.resources.filter(resourceType => { + return !resources.includes(resourceType); + }); + } + } + this._watchers = this._watchers.filter(entry => { + return !!entry.resources.length; + }); + + for (const resource of resources) { + const isResourceWatched = allWatchers.some(watcherEntry => + watcherEntry.resources.includes(resource) + ); + + if (!isResourceWatched && this._listenedResources.has(resource)) { + this._stopListening(resource); + } + } + } + + clearResources(resourceTypes) { + if (!Array.isArray(resourceTypes)) { + throw new Error("clearResources expects an array of resource types"); + } + // Clear the cached resources of the type. + this._cache = this._cache.filter( + cachedResource => !resourceTypes.includes(cachedResource.resourceType) + ); + + if (resourceTypes.length) { + this.rootFront.clearResources(resourceTypes); + } + } + + async waitForNextResource( + resourceType, + { ignoreExistingResources = false, predicate } = {} + ) { + predicate = predicate || (resource => !!resource); + + let resolve; + const promise = new Promise(r => (resolve = r)); + const onAvailable = async resources => { + const matchingResource = resources.find(resource => predicate(resource)); + if (matchingResource) { + this.unwatchResources([resourceType], { onAvailable }); + resolve(matchingResource); + } + }; + + await this.watchResources([resourceType], { + ignoreExistingResources, + onAvailable, + }); + return { onResource: promise }; + } + + async _onResourceAvailable(resources) { + for (const resource of resources) { + const { resourceType } = resource; + + resource.isAlreadyExistingResource = + this._processingExistingResources.has(resourceType); + + this._queueResourceEvent("available", resourceType, resource); + + this._cache.push(resource); + } + + this._throttledNotifyWatchers(); + } + + async _onResourceDestroyed(resources) { + for (const resource of resources) { + const { resourceType, resourceId } = resource; + + let index = -1; + if (resourceId) { + index = this._cache.findIndex( + cachedResource => + cachedResource.resourceType == resourceType && + cachedResource.resourceId == resourceId + ); + } else { + index = this._cache.indexOf(resource); + } + if (index >= 0) { + this._cache.splice(index, 1); + } else { + console.warn( + `Resource ${resourceId || ""} of ${resourceType} was not found.` + ); + } + + this._queueResourceEvent("destroyed", resourceType, resource); + } + this._throttledNotifyWatchers(); + } + + _queueResourceEvent(callbackType, resourceType, update) { + for (const { resources, pendingEvents } of this._watchers) { + if (!resources.includes(resourceType)) { + continue; + } + if (pendingEvents.length) { + const lastEvent = pendingEvents[pendingEvents.length - 1]; + if (lastEvent.callbackType == callbackType) { + lastEvent.updates.push(update); + continue; + } + } + pendingEvents.push({ + callbackType, + updates: [update], + }); + } + } + + _notifyWatchers() { + for (const watcherEntry of this._watchers) { + const { onAvailable, onDestroyed, pendingEvents } = watcherEntry; + watcherEntry.pendingEvents = []; + + for (const { callbackType, updates } of pendingEvents) { + try { + if (callbackType == "available") { + onAvailable(updates, { areExistingResources: false }); + } else if (callbackType == "destroyed" && onDestroyed) { + onDestroyed(updates); + } + } catch (e) { + console.error( + "Exception while calling a RootResourceCommand", + callbackType, + "callback", + ":", + e + ); + } + } + } + } + + _isValidResourceType(type) { + return this.ALL_TYPES.includes(type); + } + + async _startListening(resourceType) { + if (this._listenedResources.has(resourceType)) { + return; + } + this._listenedResources.add(resourceType); + + this._processingExistingResources.add(resourceType); + + // For now, if the server doesn't support the resource type + // act as if we were listening, but do nothing. + // Calling watchResources/unwatchResources will work fine, + // but no resource will be notified. + if (this.rootFront.traits.resources?.[resourceType]) { + await this.rootFront.watchResources([resourceType]); + } else { + console.warn( + `Ignored watchRequest, resourceType "${resourceType}" not found in rootFront.traits.resources` + ); + } + this._processingExistingResources.delete(resourceType); + } + + async _forwardExistingResources(resourceTypes, onAvailable) { + const existingResources = this._cache.filter(resource => + resourceTypes.includes(resource.resourceType) + ); + if (existingResources.length) { + await onAvailable(existingResources, { areExistingResources: true }); + } + } + + _stopListening(resourceType) { + if (!this._listenedResources.has(resourceType)) { + throw new Error( + `Stopped listening for resource '${resourceType}' that isn't being listened to` + ); + } + this._listenedResources.delete(resourceType); + + this._cache = this._cache.filter( + cachedResource => cachedResource.resourceType !== resourceType + ); + + if ( + !this.rootFront.isDestroyed() && + this.rootFront.traits.resources?.[resourceType] + ) { + this.rootFront.unwatchResources([resourceType]); + } + } +} + +RootResourceCommand.TYPES = RootResourceCommand.prototype.TYPES = { + EXTENSIONS_BGSCRIPT_STATUS: "extensions-backgroundscript-status", +}; +RootResourceCommand.ALL_TYPES = RootResourceCommand.prototype.ALL_TYPES = + Object.values(RootResourceCommand.TYPES); +module.exports = RootResourceCommand; diff --git a/devtools/shared/commands/script/moz.build b/devtools/shared/commands/script/moz.build new file mode 100644 index 0000000000..70570b2599 --- /dev/null +++ b/devtools/shared/commands/script/moz.build @@ -0,0 +1,10 @@ +# 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/. + +DevToolsModules( + "script-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/script/script-command.js b/devtools/shared/commands/script/script-command.js new file mode 100644 index 0000000000..cf5a7e263e --- /dev/null +++ b/devtools/shared/commands/script/script-command.js @@ -0,0 +1,157 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + getAdHocFrontOrPrimitiveGrip, + // eslint-disable-next-line mozilla/reject-some-requires +} = require("resource://devtools/client/fronts/object.js"); + +class ScriptCommand { + constructor({ commands }) { + this._commands = commands; + } + + /** + * Execute a JavaScript expression. + * + * @param {String} expression: The code you want to evaluate. + * @param {Object} options: Options for evaluation: + * @param {Object} options.frameActor: a FrameActor ID. The actor holds a reference to + * a Debugger.Frame. This option allows you to evaluate the string in the frame + * of the given FrameActor. + * @param {String} options.url: the url to evaluate the script as. Defaults to "debugger eval code". + * @param {TargetFront} options.selectedTargetFront: When passed, the expression will be + * evaluated in the context of the target (as opposed to the default, top-level one). + * @param {String} options.selectedNodeActor: A NodeActor ID that may be used by helper + * functions that can reference the currently selected node in the Inspector, like $0. + * @param {String} options.selectedObjectActor: the actorID of a given objectActor. + * This is used by context menu entries to get a reference to an object, in order + * to perform some operation on it (copy it, store it as a global variable, …). + * @param {Number} options.innerWindowID: An optional window id to be used for the evaluation, + * instead of the regular webConsoleActor.evalWindow. + * This is used by functions that may want to evaluate in a different window (for + * example a non-remote iframe), like getting the elements of a given document. + * @param {object} options.mapped: An optional object indicating if the original expression + * entered by the users have been modified + * @param {boolean} options.mapped.await: true if the expression was a top-level await + * expression that was wrapped in an async-iife + * @param {boolean} options.disableBreaks: Set to true to avoid triggering any + * type of breakpoint when evaluating the source. Also, the evaluated source won't be + * visible in the debugger UI. + * @param {boolean} options.preferConsoleCommandsOverLocalSymbols: Set to true to force + * overriding local symbols defined by the page with same-name console commands. + * + * @return {Promise}: A promise that resolves with the response. + */ + async execute(expression, options = {}) { + const { + selectedObjectActor, + selectedNodeActor, + frameActor, + selectedTargetFront, + } = options; + + // Retrieve the right WebConsole front that relates either to (by order of priority): + // - the currently selected target in the context selector + // (selectedTargetFront argument), + // - the object picked in the console (when using store as global) (selectedObjectActor), + // - the currently selected Node in the inspector (selectedNodeActor), + // - the currently selected frame in the debugger (when paused) (frameActor), + // - the currently selected target in the iframe dropdown + // (selectedTargetFront from the TargetCommand) + let targetFront = this._commands.targetCommand.selectedTargetFront; + + const selectedActor = + selectedObjectActor || selectedNodeActor || frameActor; + + if (selectedTargetFront) { + targetFront = selectedTargetFront; + } else if (selectedActor) { + const selectedFront = this._commands.client.getFrontByID(selectedActor); + if (selectedFront) { + targetFront = selectedFront.targetFront; + } + } + + const consoleFront = await targetFront.getFront("console"); + + // We call `evaluateJSAsync` RDP request, which immediately returns a simple `resultID`, + // for which we later receive a related `evaluationResult` RDP event, with the same resultID. + // The evaluation result will be contained in this RDP event. + let resultID; + const response = await new Promise(resolve => { + const offEvaluationResult = consoleFront.on( + "evaluationResult", + async packet => { + // In some cases, the evaluationResult event can be received before the call to + // evaluationJSAsync completes. So make sure to wait for the corresponding promise + // before handling the evaluationResult event. + await onEvaluateJSAsync; + + if (packet.resultID === resultID) { + resolve(packet); + offEvaluationResult(); + } + } + ); + + const onEvaluateJSAsync = consoleFront + .evaluateJSAsync({ + text: expression, + eager: options.eager, + frameActor, + innerWindowID: options.innerWindowID, + mapped: options.mapped, + selectedNodeActor, + selectedObjectActor, + url: options.url, + disableBreaks: options.disableBreaks, + preferConsoleCommandsOverLocalSymbols: + options.preferConsoleCommandsOverLocalSymbols, + }) + .then(packet => { + resultID = packet.resultID; + }); + }); + + // `response` is the packet sent via `evaluationResult` RDP event. + if (response.error) { + throw response; + } + + if (response.result) { + response.result = getAdHocFrontOrPrimitiveGrip( + response.result, + consoleFront + ); + } + + if (response.helperResult?.object) { + response.helperResult.object = getAdHocFrontOrPrimitiveGrip( + response.helperResult.object, + consoleFront + ); + } + + if (response.exception) { + response.exception = getAdHocFrontOrPrimitiveGrip( + response.exception, + consoleFront + ); + } + + if (response.exceptionMessage) { + response.exceptionMessage = getAdHocFrontOrPrimitiveGrip( + response.exceptionMessage, + consoleFront + ); + } + + return response; + } +} + +module.exports = ScriptCommand; diff --git a/devtools/shared/commands/script/tests/browser.toml b/devtools/shared/commands/script/tests/browser.toml new file mode 100644 index 0000000000..21b59c0ea3 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser.toml @@ -0,0 +1,15 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "head.js", +] + +["browser_script_command_execute_basic.js"] + +["browser_script_command_execute_document__proto__.js"] + +["browser_script_command_execute_last_result.js"] + +["browser_script_command_execute_throw.js"] diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js b/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js new file mode 100644 index 0000000000..e63f55a338 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser_script_command_execute_basic.js @@ -0,0 +1,1050 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing basic expression evaluation +const { + MAX_AUTOCOMPLETE_ATTEMPTS, + MAX_AUTOCOMPLETIONS, +} = require("resource://devtools/shared/webconsole/js-property-provider.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +add_task(async () => { + const tab = await addTab(`data:text/html;charset=utf-8, + + + Testcase + +

Body text

+ `); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + await doSimpleEval(commands); + await doWindowEval(commands); + await doEvalWithException(commands); + await doEvalWithHelper(commands); + await doEvalString(commands); + await doEvalLongString(commands); + await doEvalWithBinding(commands); + await forceLexicalInit(commands); + await doSimpleEagerEval(commands); + await doEagerEvalWithSideEffect(commands); + await doEagerEvalWithSideEffectIterator(commands); + await doEagerEvalWithSideEffectMonkeyPatched(commands); + await doEagerEvalESGetters(commands); + await doEagerEvalDOMGetters(commands); + await doEagerEvalOtherNativeGetters(commands); + await doEagerEvalAsyncFunctions(commands); + + await commands.destroy(); +}); + +async function doSimpleEval(commands) { + info("test eval '2+2'"); + const response = await commands.scriptCommand.execute("2+2"); + checkObject(response, { + input: "2+2", + result: 4, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); +} + +async function doWindowEval(commands) { + info("test eval 'document'"); + const response = await commands.scriptCommand.execute("document"); + checkObject(response, { + input: "document", + result: { + type: "object", + class: "HTMLDocument", + actor: /[a-z]/, + }, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); +} + +async function doEvalWithException(commands) { + info("test eval with exception"); + const response = await commands.scriptCommand.execute( + "window.doTheImpossible()" + ); + checkObject(response, { + input: "window.doTheImpossible()", + result: { + type: "undefined", + }, + exceptionMessage: /doTheImpossible/, + }); + + ok(response.exception, "js eval exception"); + ok(!response.helperResult, "no helper result"); +} + +async function doEvalWithHelper(commands) { + info("test eval with helper"); + const response = await commands.scriptCommand.execute("clear()"); + checkObject(response, { + input: "clear()", + result: { + type: "undefined", + }, + helperResult: { type: "clearOutput" }, + }); + + ok(!response.exception, "no eval exception"); +} + +async function doEvalString(commands) { + const response = await commands.scriptCommand.execute( + "window.foobarObject.strfoo" + ); + + checkObject(response, { + input: "window.foobarObject.strfoo", + result: "foobarz", + }); +} + +async function doEvalLongString(commands) { + const response = await commands.scriptCommand.execute( + "window.foobarObject.omgstr" + ); + + const str = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + return content.wrappedJSObject.foobarObject.omgstr; + } + ); + + const initial = str.substring(0, DevToolsServer.LONG_STRING_INITIAL_LENGTH); + + checkObject(response, { + input: "window.foobarObject.omgstr", + result: { + type: "longString", + initial, + length: str.length, + }, + }); +} + +async function doEvalWithBinding(commands) { + const response = await commands.scriptCommand.execute("document;"); + const documentActor = response.result.actorID; + + info("running a command with _self as document using selectedObjectActor"); + const selectedObjectSame = await commands.scriptCommand.execute( + "_self === document", + { + selectedObjectActor: documentActor, + } + ); + checkObject(selectedObjectSame, { + result: true, + }); +} + +async function forceLexicalInit(commands) { + info("test that failed let/const bindings are initialized to undefined"); + + const testData = [ + { + stmt: "let foopie = wubbalubadubdub", + vars: ["foopie"], + }, + { + stmt: "let {z, w={n}=null} = {}", + vars: ["z", "w"], + }, + { + stmt: "let [a, b, c] = null", + vars: ["a", "b", "c"], + }, + { + stmt: "const nein1 = rofl, nein2 = copter", + vars: ["nein1", "nein2"], + }, + { + stmt: "const {ha} = null", + vars: ["ha"], + }, + { + stmt: "const [haw=[lame]=null] = []", + vars: ["haw"], + }, + { + stmt: "const [rawr, wat=[lame]=null] = []", + vars: ["rawr", "haw"], + }, + { + stmt: "let {zzz: xyz=99, zwz: wb} = nexistepas()", + vars: ["xyz", "wb"], + }, + { + stmt: "let {c3pdoh=101} = null", + vars: ["c3pdoh"], + }, + { + stmt: "const {...x} = x", + vars: ["x"], + }, + { + stmt: "const {xx,yy,...rest} = null", + vars: ["xx", "yy", "rest"], + }, + ]; + + for (const data of testData) { + const response = await commands.scriptCommand.execute(data.stmt); + checkObject(response, { + input: data.stmt, + result: { type: "undefined" }, + }); + ok(response.exception, "expected exception"); + for (const varName of data.vars) { + const response2 = await commands.scriptCommand.execute(varName); + checkObject(response2, { + input: varName, + result: { type: "undefined" }, + }); + ok(!response2.exception, "unexpected exception"); + } + } +} + +async function doSimpleEagerEval(commands) { + const testData = [ + { + code: "2+2", + result: 4, + }, + { + code: "(x => x * 2)(3)", + result: 6, + }, + { + code: "[1, 2, 3].map(x => x * 2).join()", + result: "2,4,6", + }, + { + code: `"abc".match(/a./)[0]`, + result: "ab", + }, + { + code: "aliasedTest()", + result: "ALIASED", + }, + { + code: "testArray.concat([4,5]).join()", + result: "1,2,3,4,5", + }, + { + code: "testArray.entries().toString()", + result: "[object Array Iterator]", + }, + { + code: "testArray.keys().toString()", + result: "[object Array Iterator]", + }, + { + code: "testArray.values().toString()", + result: "[object Array Iterator]", + }, + { + code: "testArray.every(x => x < 100)", + result: true, + }, + { + code: "testArray.some(x => x > 1)", + result: true, + }, + { + code: "testArray.filter(x => x % 2 == 0).join()", + result: "2", + }, + { + code: "testArray.find(x => x % 2 == 0)", + result: 2, + }, + { + code: "testArray.findIndex(x => x % 2 == 0)", + result: 1, + }, + { + code: "[testArray].flat().join()", + result: "1,2,3", + }, + { + code: "[testArray].flatMap(x => x).join()", + result: "1,2,3", + }, + { + code: "testArray.forEach(x => x); testArray.join()", + result: "1,2,3", + }, + { + code: "testArray.includes(1)", + result: true, + }, + { + code: "testArray.lastIndexOf(1)", + result: 0, + }, + { + code: "testArray.map(x => x + 1).join()", + result: "2,3,4", + }, + { + code: "testArray.reduce((acc,x) => acc + x, 0)", + result: 6, + }, + { + code: "testArray.reduceRight((acc,x) => acc + x, 0)", + result: 6, + }, + { + code: "testArray.slice(0,1).join()", + result: "1", + }, + { + code: "testArray.toReversed().join()", + result: "3,2,1", + }, + { + code: "testArray.toSorted().join()", + result: "1,2,3", + }, + { + code: "testArray.toSpliced(0,1).join()", + result: "2,3", + }, + { + code: "testArray.with(1, 'b').join()", + result: "1,b,3", + }, + + { + code: "testInt8Array.entries().toString()", + result: "[object Array Iterator]", + }, + { + code: "testInt8Array.keys().toString()", + result: "[object Array Iterator]", + }, + { + code: "testInt8Array.values().toString()", + result: "[object Array Iterator]", + }, + { + code: "testInt8Array.every(x => x < 100)", + result: true, + }, + { + code: "testInt8Array.some(x => x > 1)", + result: true, + }, + { + code: "testInt8Array.filter(x => x % 2 == 0).join()", + result: "2", + }, + { + code: "testInt8Array.find(x => x % 2 == 0)", + result: 2, + }, + { + code: "testInt8Array.findIndex(x => x % 2 == 0)", + result: 1, + }, + { + code: "testInt8Array.forEach(x => x); testInt8Array.join()", + result: "1,2,3", + }, + { + code: "testInt8Array.includes(1)", + result: true, + }, + { + code: "testInt8Array.lastIndexOf(1)", + result: 0, + }, + { + code: "testInt8Array.map(x => x + 1).join()", + result: "2,3,4", + }, + { + code: "testInt8Array.reduce((acc,x) => acc + x, 0)", + result: 6, + }, + { + code: "testInt8Array.reduceRight((acc,x) => acc + x, 0)", + result: 6, + }, + { + code: "testInt8Array.slice(0,1).join()", + result: "1", + }, + { + code: "testInt8Array.toReversed().join()", + skip: + typeof Reflect.getPrototypeOf(Int8Array).prototype.toReversed !== + "function", + result: "3,2,1", + }, + { + code: "testInt8Array.toSorted().join()", + skip: + typeof Reflect.getPrototypeOf(Int8Array).prototype.toSorted !== + "function", + result: "1,2,3", + }, + { + code: "testInt8Array.with(1, 0).join()", + skip: + typeof Reflect.getPrototypeOf(Int8Array).prototype.with !== "function", + result: "1,0,3", + }, + ]; + + for (const { code, result, skip } of testData) { + if (skip) { + info(`Skipping evaluation of ${code}`); + continue; + } + + info(`Evaluating: ${code}`); + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject(response, { + input: code, + result, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} + +async function doEagerEvalWithSideEffect(commands) { + const testData = [ + // Modify environment. + "var a = 10; a;", + + // Directly call a funtion with side effect. + "prompt();", + + // Call a funtion with side effect inside a scripted function. + "(() => { prompt(); })()", + + // Call a funtion with side effect from self-hosted JS function. + "[1, 2, 3].map(prompt)", + + // Call a function with Function.prototype.call. + "Function.prototype.call.bind(Function.prototype.call)(prompt);", + + // Call a function with Function.prototype.apply. + "Function.prototype.apply.bind(Function.prototype.apply)(prompt);", + + // Indirectly call a function with Function.prototype.apply. + "Reflect.apply(prompt, null, []);", + "'aaaaaaaa'.replace(/(a)(a)(a)(a)(a)(a)(a)(a)/, prompt)", + + // Indirect call on obj[Symbol.iterator]().next. + "Array.from(promptIterable)", + ]; + + for (const code of testData) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject(response, { + input: code, + result: { type: "undefined" }, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} + +async function doEagerEvalWithSideEffectIterator(commands) { + // Indirect call on %ArrayIterator%.prototype.next, + + // Create an iterable object that reuses iterator across multiple call. + let response = await commands.scriptCommand.execute(` +var arr = [1, 2, 3]; +var iterator = arr[Symbol.iterator](); +var iterable = { [Symbol.iterator]() { return iterator; } }; +"ok"; +`); + checkObject(response, { + result: "ok", + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + + const testData = [ + "Array.from(iterable)", + "new Map(iterable)", + "new Set(iterable)", + ]; + + for (const code of testData) { + response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject(response, { + input: code, + result: { type: "undefined" }, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } + + // Verify the iterator's internal state isn't modified. + response = await commands.scriptCommand.execute(`[...iterator].join(",")`); + checkObject(response, { + result: "1,2,3", + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); +} + +async function doEagerEvalWithSideEffectMonkeyPatched(commands) { + // Patch the built-in function without eager evaluation. + let response = await commands.scriptCommand.execute( + `RegExp.prototype.exec = prompt; "patched"` + ); + checkObject(response, { + result: "patched", + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + + // Test eager evaluation, where the patched built-in is called internally. + // This should be aborted. + const code = `"abc".match(/a./)[0]`; + response = await commands.scriptCommand.execute(code, { eager: true }); + checkObject(response, { + input: code, + result: { type: "undefined" }, + }); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + + // Undo the patch without eager evaluation. + response = await commands.scriptCommand.execute( + `RegExp.prototype.exec = originalExec; "unpatched"` + ); + checkObject(response, { + result: "unpatched", + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + + // Test eager evaluation again, without the patch. + // This should be evaluated. + response = await commands.scriptCommand.execute(code, { eager: true }); + checkObject(response, { + input: code, + result: "ab", + }); +} + +async function doEagerEvalESGetters(commands) { + // [code, expectedResult] + const testData = [ + // ArrayBuffer + ["testArrayBuffer.byteLength", 3], + + // DataView + ["testDataView.buffer === testArrayBuffer", true], + ["testDataView.byteLength", 1], + ["testDataView.byteOffset", 2], + + // Error + ["typeof new Error().stack", "string"], + + // Function + ["typeof testFunc.arguments", "object"], + ["typeof testFunc.caller", "object"], + + // Intl.Locale + ["testLocale.baseName", "de-Latn-DE"], + ["testLocale.calendar", "gregory"], + ["testLocale.caseFirst", ""], + ["testLocale.collation", "phonebk"], + ["testLocale.hourCycle", "h23"], + ["testLocale.numeric", false], + ["testLocale.numberingSystem", "latn"], + ["testLocale.language", "de"], + ["testLocale.script", "Latn"], + ["testLocale.region", "DE"], + + // Map + ["testMap.size", 4], + + // RegExp + ["/a/.dotAll", false], + ["/a/giy.flags", "giy"], + ["/a/g.global", true], + ["/a/g.hasIndices", false], + ["/a/g.ignoreCase", false], + ["/a/g.multiline", false], + ["/a/g.source", "a"], + ["/a/g.sticky", false], + ["/a/g.unicode", false], + + // Set + ["testSet.size", 5], + + // Symbol + ["Symbol.iterator.description", "Symbol.iterator"], + + // TypedArray + ["testInt8Array.buffer === testArrayBuffer", true], + ["testInt8Array.byteLength", 3], + ["testInt8Array.byteOffset", 0], + ["testInt8Array.length", 3], + ["testInt8Array[Symbol.toStringTag]", "Int8Array"], + ]; + + for (const [code, expectedResult] of testData) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: expectedResult, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } + + // Test RegExp static properties. + // Run preparation code here to avoid interference with other tests, + // given RegExp static properties are global state. + const regexpPreparationCode = ` +/b(c)(d)(e)(f)(g)(h)(i)(j)(k)l/.test("abcdefghijklm") +`; + + const prepResponse = await commands.scriptCommand.execute( + regexpPreparationCode + ); + checkObject(prepResponse, { + input: regexpPreparationCode, + result: true, + }); + + ok(!prepResponse.exception, "no eval exception"); + ok(!prepResponse.helperResult, "no helper result"); + + const testDataRegExp = [ + // RegExp static + ["RegExp.input", "abcdefghijklm"], + ["RegExp.lastMatch", "bcdefghijkl"], + ["RegExp.lastParen", "k"], + ["RegExp.leftContext", "a"], + ["RegExp.rightContext", "m"], + ["RegExp.$1", "c"], + ["RegExp.$2", "d"], + ["RegExp.$3", "e"], + ["RegExp.$4", "f"], + ["RegExp.$5", "g"], + ["RegExp.$6", "h"], + ["RegExp.$7", "i"], + ["RegExp.$8", "j"], + ["RegExp.$9", "k"], + ["RegExp.$_", "abcdefghijklm"], // input + ["RegExp['$&']", "bcdefghijkl"], // lastMatch + ["RegExp['$+']", "k"], // lastParen + ["RegExp['$`']", "a"], // leftContext + ["RegExp[`$'`]", "m"], // rightContext + ]; + + for (const [code, expectedResult] of testDataRegExp) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: expectedResult, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } + + const testDataWithSideEffect = [ + // get Object.prototype.__proto__ + // + // This can invoke Proxy getPrototypeOf handler, which can be any native + // function, and debugger cannot hook the call. + `[].__proto__`, + `testProxy.__proto__`, + ]; + + for (const code of testDataWithSideEffect) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: { type: "undefined" }, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} + +async function doEagerEvalDOMGetters(commands) { + // Getters explicitly marked no-side-effect. + // + // [code, expectedResult] + const testDataExplicit = [ + // DOMTokenList + ["document.documentElement.classList.length", 1], + ["document.documentElement.classList.value", "class1"], + + // Document + ["document.URL.startsWith('data:')", true], + ["document.documentURI.startsWith('data:')", true], + ["document.compatMode", "CSS1Compat"], + ["document.characterSet", "UTF-8"], + ["document.charset", "UTF-8"], + ["document.inputEncoding", "UTF-8"], + ["document.contentType", "text/html"], + ["document.doctype.constructor.name", "DocumentType"], + ["document.documentElement.constructor.name", "HTMLHtmlElement"], + ["document.title", "Testcase"], + ["document.dir", "ltr"], + ["document.body.constructor.name", "HTMLBodyElement"], + ["document.head.constructor.name", "HTMLHeadElement"], + ["document.images.constructor.name", "HTMLCollection"], + ["document.embeds.constructor.name", "HTMLCollection"], + ["document.plugins.constructor.name", "HTMLCollection"], + ["document.links.constructor.name", "HTMLCollection"], + ["document.forms.constructor.name", "HTMLCollection"], + ["document.scripts.constructor.name", "HTMLCollection"], + ["document.defaultView === window", true], + ["typeof document.currentScript", "object"], + ["document.anchors.constructor.name", "HTMLCollection"], + ["document.applets.constructor.name", "HTMLCollection"], + ["document.all.constructor.name", "HTMLAllCollection"], + ["document.styleSheetSets.constructor.name", "DOMStringList"], + ["typeof document.featurePolicy", "undefined"], + ["typeof document.blockedNodeByClassifierCount", "undefined"], + ["typeof document.blockedNodesByClassifier", "undefined"], + ["typeof document.permDelegateHandler", "undefined"], + ["document.children.constructor.name", "HTMLCollection"], + ["document.firstElementChild === document.documentElement", true], + ["document.lastElementChild === document.documentElement", true], + ["document.childElementCount", 1], + ["document.location.href.startsWith('data:')", true], + + // Element + ["document.body.namespaceURI", "http://www.w3.org/1999/xhtml"], + ["document.body.prefix === null", true], + ["document.body.localName", "body"], + ["document.body.tagName", "BODY"], + ["document.body.id", "body1"], + ["document.body.className", "class2"], + ["document.body.classList.constructor.name", "DOMTokenList"], + ["document.body.part.constructor.name", "DOMTokenList"], + ["document.body.attributes.constructor.name", "NamedNodeMap"], + ["document.body.innerHTML.includes('Body text')", true], + ["document.body.outerHTML.includes('Body text')", true], + ["document.body.previousElementSibling !== null", true], + ["document.body.nextElementSibling === null", true], + ["document.body.children.constructor.name", "HTMLCollection"], + ["document.body.firstElementChild !== null", true], + ["document.body.lastElementChild !== null", true], + ["document.body.childElementCount", 1], + + // Node + ["document.body.nodeType === Node.ELEMENT_NODE", true], + ["document.body.nodeName", "BODY"], + ["document.body.baseURI.startsWith('data:')", true], + ["document.body.isConnected", true], + ["document.body.ownerDocument === document", true], + ["document.body.parentNode === document.documentElement", true], + ["document.body.parentElement === document.documentElement", true], + ["document.body.childNodes.constructor.name", "NodeList"], + ["document.body.firstChild !== null", true], + ["document.body.lastChild !== null", true], + ["document.body.previousSibling !== null", true], + ["document.body.nextSibling === null", true], + ["document.body.nodeValue === null", true], + ["document.body.textContent.includes('Body text')", true], + ["typeof document.body.flattenedTreeParentNode", "undefined"], + ["typeof document.body.isNativeAnonymous", "undefined"], + ["typeof document.body.containingShadowRoot", "undefined"], + ["typeof document.body.accessibleNode", "undefined"], + + // Performance + ["performance.timeOrigin > 0", true], + ["performance.timing.constructor.name", "PerformanceTiming"], + ["performance.navigation.constructor.name", "PerformanceNavigation"], + ["performance.eventCounts.constructor.name", "EventCounts"], + + // window + ["window.window === window", true], + ["window.self === window", true], + ["window.document.constructor.name", "HTMLDocument"], + ["window.performance.constructor.name", "Performance"], + ["typeof window.browsingContext", "undefined"], + ["typeof window.windowUtils", "undefined"], + ["typeof window.windowGlobalChild", "undefined"], + ["window.visualViewport.constructor.name", "VisualViewport"], + ["typeof window.caches", "undefined"], + ["window.location.href.startsWith('data:')", true], + ]; + if (typeof Scheduler === "function") { + // Scheduler is behind a pref. + testDataExplicit.push(["window.scheduler.constructor.name", "Scheduler"]); + } + + for (const [code, expectedResult] of testDataExplicit) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: expectedResult, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } + + // Getters not-explicitly marked no-side-effect. + // All DOM getters are considered no-side-effect in eager evaluation context. + const testDataImplicit = [ + // NOTE: This is not an exhaustive list. + // Document + [`document.implementation.constructor.name`, "DOMImplementation"], + [`typeof document.domain`, "string"], + [`typeof document.referrer`, "string"], + [`typeof document.cookie`, "string"], + [`typeof document.lastModified`, "string"], + [`typeof document.readyState`, "string"], + [`typeof document.designMode`, "string"], + [`typeof document.onbeforescriptexecute`, "object"], + [`typeof document.onafterscriptexecute`, "object"], + + // Element + [`typeof document.documentElement.scrollTop`, "number"], + [`typeof document.documentElement.scrollLeft`, "number"], + [`typeof document.documentElement.scrollWidth`, "number"], + [`typeof document.documentElement.scrollHeight`, "number"], + + // Performance + [`typeof performance.onresourcetimingbufferfull`, "object"], + + // window + [`typeof window.name`, "string"], + [`window.history.constructor.name`, "History"], + [`window.customElements.constructor.name`, "CustomElementRegistry"], + [`window.locationbar.constructor.name`, "BarProp"], + [`window.menubar.constructor.name`, "BarProp"], + [`typeof window.status`, "string"], + [`window.closed`, false], + + // CanvasRenderingContext2D / CanvasCompositing + [`testCanvasContext.globalAlpha`, 1], + ]; + + for (const [code, expectedResult] of testDataImplicit) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: expectedResult, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} + +async function doEagerEvalOtherNativeGetters(commands) { + // DOM getter functions are allowed to be eagerly-evaluated. + // Test the situation where non-DOM-getter function is called by accessing + // getter. + // + // "being a DOM getter" is tested by checking if the native function has + // JSJitInfo and it's marked as getter. + const testData = [ + // Has no JitInfo. + "objWithNativeGetter.print", + "objWithNativeGetter.Element", + + // Not marked as getter, but method. + "objWithNativeGetter.getAttribute", + + // Not marked as getter, but setter. + "objWithNativeGetter.setClassName", + + // Not marked as getter, but static method. + "objWithNativeGetter.requestPermission", + ]; + + for (const code of testData) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: { type: "undefined" }, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} + +async function doEagerEvalAsyncFunctions(commands) { + // [code, expectedResult] + const testData = [["typeof testAsync()", "object"]]; + + for (const [code, expectedResult] of testData) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: expectedResult, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } + + const testDataWithSideEffect = [ + // await is effectful + "testAsyncAwait()", + + // initial yield is effectful + "testAsyncGen()", + "testAsyncGenAwait()", + ]; + + for (const code of testDataWithSideEffect) { + const response = await commands.scriptCommand.execute(code, { + eager: true, + }); + checkObject( + response, + { + input: code, + result: { type: "undefined" }, + }, + code + ); + + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); + } +} diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js b/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js new file mode 100644 index 0000000000..28f56ebac3 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser_script_command_execute_document__proto__.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing evaluating document.__proto__ + +add_task(async () => { + const tab = await addTab( + `data:text/html;charset=utf-8,Test evaluating document.__proto__` + ); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const evaluationResponse = await commands.scriptCommand.execute( + "document.__proto__" + ); + checkObject(evaluationResponse, { + input: "document.__proto__", + result: { + type: "object", + actor: /[a-z]/, + }, + }); + + ok(!evaluationResponse.exception, "no eval exception"); + ok(!evaluationResponse.helperResult, "no helper result"); + + const response = await evaluationResponse.result.getPrototypeAndProperties(); + ok(!response.error, "no response error"); + + const props = response.ownProperties; + ok(props, "response properties available"); + + const expectedProps = Object.getOwnPropertyNames( + Object.getPrototypeOf(document) + ); + checkObject(Object.keys(props), expectedProps, "Same own properties."); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js b/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js new file mode 100644 index 0000000000..aebdaeb168 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser_script_command_execute_last_result.js @@ -0,0 +1,85 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing that last evaluation result can be accessed with `$_` + +add_task(async () => { + const tab = await addTab(`data:text/html;charset=utf-8,`); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + info("$_ returns undefined if nothing has evaluated yet"); + let response = await commands.scriptCommand.execute("$_"); + basicResultCheck(response, "$_", { type: "undefined" }); + + info("$_ returns last value and performs basic arithmetic"); + response = await commands.scriptCommand.execute("2+2"); + basicResultCheck(response, "2+2", 4); + + response = await commands.scriptCommand.execute("$_"); + basicResultCheck(response, "$_", 4); + + response = await commands.scriptCommand.execute("$_ + 2"); + basicResultCheck(response, "$_ + 2", 6); + + response = await commands.scriptCommand.execute("$_ + 4"); + basicResultCheck(response, "$_ + 4", 10); + + info("$_ has correct references to objects"); + response = await commands.scriptCommand.execute("var foo = {bar:1}; foo;"); + basicResultCheck(response, "var foo = {bar:1}; foo;", { + type: "object", + class: "Object", + actor: /[a-z]/, + }); + checkObject(response.result.getGrip().preview.ownProperties, { + bar: { + value: 1, + }, + }); + + response = await commands.scriptCommand.execute("$_"); + basicResultCheck(response, "$_", { + type: "object", + class: "Object", + actor: /[a-z]/, + }); + checkObject(response.result.getGrip().preview.ownProperties, { + bar: { + value: 1, + }, + }); + + info( + "Update a property value and check that evaluating $_ returns the expected object instance" + ); + await ContentTask.spawn(gBrowser.selectedBrowser, null, () => { + content.wrappedJSObject.foo.bar = "updated_value"; + }); + + response = await commands.scriptCommand.execute("$_"); + basicResultCheck(response, "$_", { + type: "object", + class: "Object", + actor: /[a-z]/, + }); + checkObject(response.result.getGrip().preview.ownProperties, { + bar: { + value: "updated_value", + }, + }); + + await commands.destroy(); +}); + +function basicResultCheck(response, input, output) { + checkObject(response, { + input, + result: output, + }); + ok(!response.exception, "no eval exception"); + ok(!response.helperResult, "no helper result"); +} diff --git a/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js b/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js new file mode 100644 index 0000000000..8680193ecb --- /dev/null +++ b/devtools/shared/commands/script/tests/browser_script_command_execute_throw.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Testing evaluating thowing expressions +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +add_task(async () => { + const tab = await addTab(`data:text/html;charset=utf-8,Test throw`); + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + const falsyValues = [ + "-0", + "null", + "undefined", + "Infinity", + "-Infinity", + "NaN", + ]; + for (const value of falsyValues) { + const response = await commands.scriptCommand.execute(`throw ${value};`); + is( + response.exception.type, + value, + `Got the expected value for response.exception.type when throwing "${value}"` + ); + } + + const identityTestValues = [false, 0]; + for (const value of identityTestValues) { + const response = await commands.scriptCommand.execute(`throw ${value};`); + is( + response.exception, + value, + `Got the expected value for response.exception when throwing "${value}"` + ); + } + + const symbolTestValues = [ + ["Symbol.iterator", "Symbol(Symbol.iterator)"], + ["Symbol('foo')", "Symbol(foo)"], + ["Symbol()", "Symbol()"], + ]; + for (const [expr, message] of symbolTestValues) { + const response = await commands.scriptCommand.execute(`throw ${expr};`); + is( + response.exceptionMessage, + message, + `Got the expected value for response.exceptionMessage when throwing "${expr}"` + ); + } + + const longString = Array(DevToolsServer.LONG_STRING_LENGTH + 1).join("a"), + shortedString = longString.substring( + 0, + DevToolsServer.LONG_STRING_INITIAL_LENGTH + ); + const response = await commands.scriptCommand.execute( + "throw '" + longString + "';" + ); + is( + response.exception.initial, + shortedString, + "Got the expected value for exception.initial when throwing a longString" + ); + is( + response.exceptionMessage.initial, + shortedString, + "Got the expected value for exceptionMessage.initial when throwing a longString" + ); +}); diff --git a/devtools/shared/commands/script/tests/head.js b/devtools/shared/commands/script/tests/head.js new file mode 100644 index 0000000000..50635e4502 --- /dev/null +++ b/devtools/shared/commands/script/tests/head.js @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +function checkObject(object, expected, message) { + 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, message); + } +} + +function checkValue(name, value, expected, message) { + if (message) { + message = ` for '${message}'`; + } + + if (expected === null) { + is(value, null, `'${name}' is null${message}`); + } else if (expected === undefined) { + is(value, expected, `'${name}' is undefined${message}`); + } else if ( + typeof expected == "string" || + typeof expected == "number" || + typeof expected == "boolean" + ) { + is(value, expected, "property '" + name + "'" + message); + } else if (expected instanceof RegExp) { + ok( + expected.test(value), + name + ": " + expected + " matched " + value + message + ); + } else if (Array.isArray(expected)) { + info("checking array for property '" + name + "'" + message); + checkObject(value, expected, message); + } else if (typeof expected == "object") { + info("checking object for property '" + name + "'" + message); + checkObject(value, expected, message); + } +} diff --git a/devtools/shared/commands/target-configuration/moz.build b/devtools/shared/commands/target-configuration/moz.build new file mode 100644 index 0000000000..c0929aee77 --- /dev/null +++ b/devtools/shared/commands/target-configuration/moz.build @@ -0,0 +1,10 @@ +# 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/. + +DevToolsModules( + "target-configuration-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/target-configuration/target-configuration-command.js b/devtools/shared/commands/target-configuration/target-configuration-command.js new file mode 100644 index 0000000000..28e717cea2 --- /dev/null +++ b/devtools/shared/commands/target-configuration/target-configuration-command.js @@ -0,0 +1,124 @@ +/* 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"; + +/** + * The TargetConfigurationCommand should be used to populate the DevTools server + * with settings read from the client side but which impact the server. + * For instance, "disable cache" is a feature toggled via DevTools UI (client), + * but which should be communicated to the targets (server). + * + * See the TargetConfigurationActor for a list of supported configuration options. + */ +class TargetConfigurationCommand { + constructor({ commands, watcherFront }) { + this._commands = commands; + this._watcherFront = watcherFront; + } + + /** + * Return a promise that resolves to the related target configuration actor's front. + * + * @return {Promise} + */ + async getFront() { + const front = await this._watcherFront.getTargetConfigurationActor(); + + if (!this._configuration) { + // Retrieve initial data from the front + this._configuration = front.initialConfiguration; + } + + return front; + } + + _hasTargetWatcherSupport() { + return this._commands.targetCommand.hasTargetWatcherSupport(); + } + + /** + * Retrieve the current map of configuration options pushed to the server. + */ + get configuration() { + return this._configuration || {}; + } + + async updateConfiguration(configuration) { + if (this._hasTargetWatcherSupport()) { + const front = await this.getFront(); + const updatedConfiguration = await front.updateConfiguration( + configuration + ); + // Update the client-side copy of the DevTools configuration + this._configuration = updatedConfiguration; + } else { + await this._commands.targetCommand.targetFront.reconfigure({ + options: configuration, + }); + } + } + + async isJavascriptEnabled() { + // If we don't have target watcher support, we can't get this value, so just + // fall back to true. Only content tab targets can update javascriptEnabled + // and all should have watcher support. + if (!this._hasTargetWatcherSupport()) { + return true; + } + + const front = await this.getFront(); + return front.isJavascriptEnabled(); + } + + /** + * Reports if the given configuration key is supported by the server. + * If the debugged context doesn't support the watcher actor, + * we won't be using the target configuration actor and report all keys + * as not supported. + * + * @param {Object} configurationKey + * Name of the configuration you would like to set. + * @return {Promise} True, if this configuration can be set via this API. + */ + async supports(configurationKey) { + if (!this._hasTargetWatcherSupport()) { + return false; + } + const front = await this.getFront(); + return !!front.traits.supportedOptions[configurationKey]; + } + + /** + * Change orientation type and angle (that can be accessed through screen.orientation in + * the content page) and simulates the "orientationchange" event when the device screen + * was rotated. + * Note that this will only be effective if the Responsive Design Mode is enabled. + * + * @param {Object} options + * @param {String} options.type: The orientation type of the rotated device. + * @param {Number} options.angle: The rotated angle of the device. + * @param {Boolean} options.isViewportRotated: Whether or not screen orientation change + * is a result of rotating the viewport. If true, an "orientationchange" + * event will be dispatched in the content window. + */ + async simulateScreenOrientationChange({ type, angle, isViewportRotated }) { + // We need to call the method on the parent process + await this.updateConfiguration({ + rdmPaneOrientation: { type, angle }, + }); + + // Don't dispatch the "orientationchange" event if orientation change is a result + // of switching to a new device, location change, or opening RDM. + if (!isViewportRotated) { + return; + } + + const responsiveFront = + await this._commands.targetCommand.targetFront.getFront("responsive"); + await responsiveFront.dispatchOrientationChangeEvent(); + } +} + +module.exports = TargetConfigurationCommand; diff --git a/devtools/shared/commands/target-configuration/tests/browser.toml b/devtools/shared/commands/target-configuration/tests/browser.toml new file mode 100644 index 0000000000..d13ac34bd6 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser.toml @@ -0,0 +1,34 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "target_configuration_test_doc.sjs", + "head.js", +] + +["browser_target_configuration_command.js"] + +["browser_target_configuration_command_color_scheme.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_target_configuration_command_custom_user_agent.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_target_configuration_command_dppx.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] + +["browser_target_configuration_command_touch_events.js"] +skip-if = [ + "http3", # Bug 1829298 + "http2", +] diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js new file mode 100644 index 0000000000..47dab1baa9 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the watcher's target-configuration actor API. + +add_task(async function () { + info("Setup the test page with workers of all types"); + const tab = await addTab("data:text/html;charset=utf-8,Configuration actor"); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + compareOptions( + targetConfigurationCommand.configuration, + {}, + "Initial configuration is empty" + ); + + await targetConfigurationCommand.updateConfiguration({ + cacheDisabled: true, + }); + compareOptions( + targetConfigurationCommand.configuration, + { cacheDisabled: true }, + "Option cacheDisabled was set" + ); + + await targetConfigurationCommand.updateConfiguration({ + javascriptEnabled: false, + }); + compareOptions( + targetConfigurationCommand.configuration, + { cacheDisabled: true, javascriptEnabled: false }, + "Option javascriptEnabled was set" + ); + + await targetConfigurationCommand.updateConfiguration({ + cacheDisabled: false, + }); + compareOptions( + targetConfigurationCommand.configuration, + { cacheDisabled: false, javascriptEnabled: false }, + "Option cacheDisabled was updated" + ); + + await targetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + compareOptions( + targetConfigurationCommand.configuration, + { + cacheDisabled: false, + colorSchemeSimulation: "dark", + javascriptEnabled: false, + }, + "Option colorSchemeSimulation was set, with a string value" + ); + + await targetConfigurationCommand.updateConfiguration({ + setTabOffline: true, + }); + compareOptions( + targetConfigurationCommand.configuration, + { + cacheDisabled: false, + colorSchemeSimulation: "dark", + javascriptEnabled: false, + setTabOffline: true, + }, + "Option setTabOffline was set on" + ); + + await targetConfigurationCommand.updateConfiguration({ + setTabOffline: false, + }); + compareOptions( + targetConfigurationCommand.configuration, + { + setTabOffline: false, + cacheDisabled: false, + colorSchemeSimulation: "dark", + javascriptEnabled: false, + }, + "Option setTabOffline was set off" + ); + + targetCommand.destroy(); + await commands.destroy(); +}); + +function compareOptions(options, expected, message) { + is( + Object.keys(options).length, + Object.keys(expected).length, + message + " (wrong number of options)" + ); + + for (const key of Object.keys(expected)) { + is(options[key], expected[key], message + ` (wrong value for ${key})`); + } +} diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js new file mode 100644 index 0000000000..509ecb84b2 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_color_scheme.js @@ -0,0 +1,183 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test color scheme simulation. +const TEST_DOCUMENT = "target_configuration_test_doc.sjs"; +const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +add_task(async function () { + info("Setup the test page with workers of all types"); + const tab = await addTab(TEST_URI); + + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + false, + "The dark mode simulation wasn't enabled in the content page when it loaded" + ); + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + false, + "The dark mode simulation isn't enabled in the content page by default" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + false, + "The dark mode simulation wasn't enabled in the remote iframe when it loaded" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + false, + "The dark mode simulation isn't enabled in the remote iframe by default" + ); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + info("Update configuration to enable dark mode simulation"); + await targetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled after updating the configuration" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the remote iframe after updating the configuration" + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + true, + "The dark mode simulation was enabled in the content page when it loaded after reloading" + ); + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the content page after reloading" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + true, + "The dark mode simulation was enabled in the remote iframe when it loaded after reloading" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the remote iframe after reloading" + ); + + const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id; + info( + "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled" + ); + + const onPageLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + /* includeSubFrames */ true + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await onPageLoaded; + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + true, + "The dark mode simulation was enabled in the content page when it loaded after navigating to a new browsing context" + ); + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the content page after navigating to a new browsing context" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup(), + true, + "The dark mode simulation was enabled in the remote iframe when it loaded after navigating to a new browsing context" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + true, + "The dark mode simulation is enabled in the remote iframe after navigating to a new browsing context" + ); + + targetCommand.destroy(); + await commands.destroy(); + + is( + await topLevelDocumentMatchPrefersDarkColorSchemeMedia(), + false, + "The dark mode simulation is disabled in the content page after destroying the commands" + ); + is( + await iframeDocumentMatchPrefersDarkColorSchemeMedia(), + false, + "The dark mode simulation is disabled in the remote iframe after destroying the commands" + ); +}); + +function matchPrefersDarkColorSchemeMedia(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.matchMedia("(prefers-color-scheme: dark)").matches + ); +} + +function matchPrefersDarkColorSchemeMediaAtStartup(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialMatchesPrefersDarkColorScheme + ); +} + +function topLevelDocumentMatchPrefersDarkColorSchemeMedia() { + return matchPrefersDarkColorSchemeMedia(gBrowser.selectedBrowser); +} + +function topLevelDocumentMatchPrefersDarkColorSchemeMediaAtStartup() { + return matchPrefersDarkColorSchemeMediaAtStartup(gBrowser.selectedBrowser); +} + +function getIframeBrowsingContext() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + // Ensure we've rendered the iframe so that the prefers-color-scheme + // value propagated from the embedder is up-to-date. + await new Promise(resolve => { + content.requestAnimationFrame(() => + content.requestAnimationFrame(resolve) + ); + }); + return content.document.querySelector("iframe").browsingContext; + }); +} + +async function iframeDocumentMatchPrefersDarkColorSchemeMedia() { + const iframeBC = await getIframeBrowsingContext(); + return matchPrefersDarkColorSchemeMedia(iframeBC); +} + +async function iframeDocumentMatchPrefersDarkColorSchemeMediaAtStartup() { + const iframeBC = await getIframeBrowsingContext(); + return matchPrefersDarkColorSchemeMediaAtStartup(iframeBC); +} diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js new file mode 100644 index 0000000000..3f342a1ac9 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_custom_user_agent.js @@ -0,0 +1,309 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test setting custom user agent. +const TEST_DOCUMENT = "target_configuration_test_doc.sjs"; +const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + info("Create commands for the tab"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const initialUserAgent = await getTopLevelUserAgent(); + + info("Update configuration to change user agent"); + const CUSTOM_USER_AGENT = ""; + + await targetConfigurationCommand.updateConfiguration({ + customUserAgent: CUSTOM_USER_AGENT, + }); + + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The user agent is properly set on the top level document after updating the configuration" + ); + is( + await getUserAgentForTopLevelRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources on the top level document" + ); + + is( + await getIframeUserAgent(), + CUSTOM_USER_AGENT, + "The user agent is properly set on the iframe after updating the configuration" + ); + is( + await getUserAgentForIframeRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources on the iframe" + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + + is( + await getTopLevelDocumentUserAgentAtStartup(), + CUSTOM_USER_AGENT, + "The custom user agent was set in the content page when it loaded after reloading" + ); + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is set in the content page after reloading" + ); + is( + await getUserAgentForTopLevelRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources after reloading" + ); + is( + await getIframeUserAgentAtStartup(), + CUSTOM_USER_AGENT, + "The custom user agent was set in the remote iframe when it loaded after reloading" + ); + is( + await getIframeUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is set in the remote iframe after reloading" + ); + is( + await getUserAgentForIframeRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources in the remote iframe after reloading" + ); + + const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id; + info( + "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled" + ); + + const onPageLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + /* includeSubFrames */ true + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await onPageLoaded; + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await getTopLevelDocumentUserAgentAtStartup(), + CUSTOM_USER_AGENT, + "The custom user agent was set in the content page when it loaded after navigating to a new browsing context" + ); + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is set in the content page after navigating to a new browsing context" + ); + is( + await getUserAgentForTopLevelRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources after navigating to a new browsing context" + ); + is( + await getIframeUserAgentAtStartup(), + CUSTOM_USER_AGENT, + "The custom user agent was set in the remote iframe when it loaded after navigating to a new browsing context" + ); + is( + await getIframeUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is set in the remote iframe after navigating to a new browsing context" + ); + is( + await getUserAgentForTopLevelRequest(commands), + CUSTOM_USER_AGENT, + "The custom user agent is used when retrieving resources in the remote iframes after navigating to a new browsing context" + ); + + info( + "Create another commands instance and check that destroying it won't reset the user agent" + ); + const otherCommands = await CommandsFactory.forTab(tab); + const otherTargetConfigurationCommand = + otherCommands.targetConfigurationCommand; + const otherTargetCommand = otherCommands.targetCommand; + await otherTargetCommand.startListening(); + // wait for the target to be fully attached to avoid pending connection to the server + await otherTargetCommand.watchTargets({ + types: [otherTargetCommand.TYPES.FRAME], + onAvailable: () => {}, + }); + + // Let's update the configuration with this commands instance to make sure we hit the TargetConfigurationActor + await otherTargetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + + otherTargetCommand.destroy(); + await otherCommands.destroy(); + + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is still set on the page after destroying another commands instance" + ); + + info( + "Check that destroying the commands we set the user agent in will reset the user agent" + ); + targetCommand.destroy(); + await commands.destroy(); + + // XXX: This is needed at the moment since Navigator.cpp retrieve the UserAgent from the + // headers (when there's no custom user agent). And here, since we reloaded the page once + // we set the custom user agent, the header was set accordingly and still holds the custom + // user agent value. This should be fixed by Bug 1705326. + is( + await getTopLevelUserAgent(), + CUSTOM_USER_AGENT, + "The custom user agent is still set on the page after destroying the first commands instance. Bug 1705326 will fix that and make it equal to `initialUserAgent`" + ); + + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + is( + await getTopLevelUserAgent(), + initialUserAgent, + "The user agent was reset in the content page after destroying the commands" + ); + is( + await getIframeUserAgent(), + initialUserAgent, + "The user agent was reset in the remote iframe after destroying the commands" + ); + + // We need commands to retrieve the headers of the network request, and + // all those we created so far were destroyed; let's create new ones. + const newCommands = await CommandsFactory.forTab(tab); + await newCommands.targetCommand.startListening(); + is( + await getUserAgentForTopLevelRequest(newCommands), + initialUserAgent, + "The initial user agent is used when retrieving resources after destroying the commands" + ); + is( + await getUserAgentForIframeRequest(newCommands), + initialUserAgent, + "The initial user agent is used when retrieving resources on the remote iframe after destroying the commands" + ); +}); + +function getUserAgent(browserOrBrowsingContext) { + return SpecialPowers.spawn(browserOrBrowsingContext, [], () => { + return content.navigator.userAgent; + }); +} + +function getUserAgentAtStartup(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialUserAgent + ); +} + +function getTopLevelUserAgent() { + return getUserAgent(gBrowser.selectedBrowser); +} + +function getTopLevelDocumentUserAgentAtStartup() { + return getUserAgentAtStartup(gBrowser.selectedBrowser); +} + +function getIframeBrowsingContext() { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); +} + +async function getIframeUserAgent() { + const iframeBC = await getIframeBrowsingContext(); + return getUserAgent(iframeBC); +} + +async function getIframeUserAgentAtStartup() { + const iframeBC = await getIframeBrowsingContext(); + return getUserAgentAtStartup(iframeBC); +} + +async function getRequestUserAgent(commands, browserOrBrowsingContext) { + const url = `unknown?${Date.now()}`; + + // Wait for the resource and its headers to be available + const onAvailable = () => {}; + let onUpdated; + + const onResource = new Promise(resolve => { + onUpdated = updates => { + for (const { resource } of updates) { + if (resource.url.includes(url) && resource.requestHeadersAvailable) { + resolve(resource); + } + } + }; + + commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + ignoreExistingResources: true, + } + ); + }); + + info(`Fetch ${url}`); + SpecialPowers.spawn(browserOrBrowsingContext, [url], innerUrl => { + content.fetch(`./${innerUrl}`); + }); + info("waiting for matching resource…"); + const networkResource = await onResource; + + info("…got resource, retrieve headers"); + const packet = { + to: networkResource.actor, + type: "getRequestHeaders", + }; + + const { headers } = await commands.client.request(packet); + + commands.resourceCommand.unwatchResources( + [commands.resourceCommand.TYPES.NETWORK_EVENT], + { + onAvailable, + onUpdated, + ignoreExistingResources: true, + } + ); + + return headers.find(header => header.name == "User-Agent")?.value; +} + +async function getUserAgentForTopLevelRequest(commands) { + return getRequestUserAgent(commands, gBrowser.selectedBrowser); +} + +async function getUserAgentForIframeRequest(commands) { + const iframeBC = await getIframeBrowsingContext(); + return getRequestUserAgent(commands, iframeBC); +} diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js new file mode 100644 index 0000000000..744ac2c403 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_dppx.js @@ -0,0 +1,187 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test device pixel ratio override. +const TEST_DOCUMENT = "target_configuration_test_doc.sjs"; +const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + info("Create commands for the tab"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const originalDpr = await getTopLevelDocumentDevicePixelRatio(); + + info("Update configuration to change device pixel ratio"); + const CUSTOM_DPR = 5.5; + + await targetConfigurationCommand.updateConfiguration({ + overrideDPPX: CUSTOM_DPR, + }); + + is( + await getTopLevelDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The ratio is properly set on the top level document after updating the configuration" + ); + is( + await getIframeDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The ratio is properly set on the iframe after updating the configuration" + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + + is( + await getTopLevelDocumentDevicePixelRatioAtStartup(), + CUSTOM_DPR, + "The custom ratio was set in the content page when it loaded after reloading" + ); + is( + await getTopLevelDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is set in the content page after reloading" + ); + is( + await getIframeDocumentDevicePixelRatioAtStartup(), + CUSTOM_DPR, + "The custom ratio was set in the remote iframe when it loaded after reloading" + ); + is( + await getIframeDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is set in the remote iframe after reloading" + ); + + const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id; + info( + "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled" + ); + + const onPageLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + /* includeSubFrames */ true + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await onPageLoaded; + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await getTopLevelDocumentDevicePixelRatioAtStartup(), + CUSTOM_DPR, + "The custom ratio was set in the content page when it loaded after navigating to a new browsing context" + ); + is( + await getTopLevelDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is set in the content page after navigating to a new browsing context" + ); + is( + await getIframeDocumentDevicePixelRatioAtStartup(), + CUSTOM_DPR, + "The custom ratio was set in the remote iframe when it loaded after navigating to a new browsing context" + ); + is( + await getIframeDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is set in the remote iframe after navigating to a new browsing context" + ); + + info( + "Create another commands instance and check that destroying it won't reset the ratio" + ); + const otherCommands = await CommandsFactory.forTab(tab); + const otherTargetConfigurationCommand = + otherCommands.targetConfigurationCommand; + const otherTargetCommand = otherCommands.targetCommand; + await otherTargetCommand.startListening(); + + // Let's update the configuration with this commands instance to make sure we hit the TargetConfigurationActor + await otherTargetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + + otherTargetCommand.destroy(); + await otherCommands.destroy(); + + is( + await getTopLevelDocumentDevicePixelRatio(), + CUSTOM_DPR, + "The custom ratio is still set on the page after destroying another commands instance" + ); + + info( + "Check that destroying the commands we overrode the ratio in will reset the page ratio" + ); + targetCommand.destroy(); + await commands.destroy(); + + is( + await getTopLevelDocumentDevicePixelRatio(), + originalDpr, + "The ratio was reset in the content page after destroying the commands" + ); + is( + await getIframeDocumentDevicePixelRatio(), + originalDpr, + "The ratio was reset in the remote iframe after destroying the commands" + ); +}); + +function getDevicePixelRatio(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.browsingContext.top.overrideDPPX || content.devicePixelRatio + ); +} + +function getDevicePixelRatioAtStartup(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialDevicePixelRatio + ); +} + +function getTopLevelDocumentDevicePixelRatio() { + return getDevicePixelRatio(gBrowser.selectedBrowser); +} + +function getTopLevelDocumentDevicePixelRatioAtStartup() { + return getDevicePixelRatioAtStartup(gBrowser.selectedBrowser); +} + +function getIframeBrowsingContext() { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); +} + +async function getIframeDocumentDevicePixelRatio() { + const iframeBC = await getIframeBrowsingContext(); + return getDevicePixelRatio(iframeBC); +} + +async function getIframeDocumentDevicePixelRatioAtStartup() { + const iframeBC = await getIframeBrowsingContext(); + return getDevicePixelRatioAtStartup(iframeBC); +} diff --git a/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js new file mode 100644 index 0000000000..683dd6d999 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command_touch_events.js @@ -0,0 +1,264 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test touch event simulation. +const TEST_DOCUMENT = "target_configuration_test_doc.sjs"; +const TEST_URI = URL_ROOT_COM_SSL + TEST_DOCUMENT; + +add_task(async function () { + // Disable click hold and double tap zooming as it might interfere with the test + await pushPref("ui.click_hold_context_menus", false); + await pushPref("apz.allow_double_tap_zooming", false); + + const tab = await addTab(TEST_URI); + + info("Create commands for the tab"); + const commands = await CommandsFactory.forTab(tab); + + const targetConfigurationCommand = commands.targetConfigurationCommand; + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + info("Touch simulation is disabled at the beginning"); + await checkTopLevelDocumentTouchSimulation({ enabled: false }); + await checkIframeTouchSimulation({ + enabled: false, + }); + + info("Enable touch simulation"); + await targetConfigurationCommand.updateConfiguration({ + touchEventsOverride: "enabled", + }); + await checkTopLevelDocumentTouchSimulation({ enabled: true }); + await checkIframeTouchSimulation({ + enabled: true, + }); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + + is( + await topLevelDocumentMatchesCoarsePointerAtStartup(), + true, + "The touch simulation was enabled in the content page when it loaded after reloading" + ); + await checkTopLevelDocumentTouchSimulation({ enabled: true }); + + is( + await iframeMatchesCoarsePointerAtStartup(), + true, + "The touch simulation was enabled in the iframe when it loaded after reloading" + ); + await checkIframeTouchSimulation({ + enabled: true, + }); + + info( + "Create another commands instance and check that destroying it won't reset the touch simulation" + ); + const otherCommands = await CommandsFactory.forTab(tab); + const otherTargetConfigurationCommand = + otherCommands.targetConfigurationCommand; + const otherTargetCommand = otherCommands.targetCommand; + + await otherTargetCommand.startListening(); + // Watch targets so we wait for server communication to settle (e.g. attach calls), as + // this could cause intermittent failures. + await otherTargetCommand.watchTargets({ + types: [otherTargetCommand.TYPES.FRAME], + onAvailable: () => {}, + }); + + // Let's update the configuration with this commands instance to make sure we hit the TargetConfigurationActor + await otherTargetConfigurationCommand.updateConfiguration({ + colorSchemeSimulation: "dark", + }); + + otherTargetCommand.destroy(); + await otherCommands.destroy(); + + await checkTopLevelDocumentTouchSimulation({ enabled: true }); + await checkIframeTouchSimulation({ + enabled: true, + }); + + const previousBrowsingContextId = gBrowser.selectedBrowser.browsingContext.id; + info( + "Check that navigating to a page that forces the creation of a new browsing context keep the simulation enabled" + ); + + const onBrowserLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + true + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + URL_ROOT_ORG_SSL + TEST_DOCUMENT + "?crossOriginIsolated=true" + ); + await onBrowserLoaded; + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + is( + await topLevelDocumentMatchesCoarsePointerAtStartup(), + true, + "The touch simulation was enabled in the content page when it loaded after navigating to a new browsing context" + ); + await checkTopLevelDocumentTouchSimulation({ + enabled: true, + }); + + is( + await iframeMatchesCoarsePointerAtStartup(), + true, + "The touch simulation was enabled in the iframe when it loaded after navigating to a new browsing context" + ); + await checkIframeTouchSimulation({ + enabled: true, + }); + + info( + "Check that destroying the commands we enabled the simulation in will disable the simulation" + ); + targetCommand.destroy(); + await commands.destroy(); + + await checkTopLevelDocumentTouchSimulation({ enabled: false }); + await checkIframeTouchSimulation({ + enabled: false, + }); +}); + +function matchesCoarsePointer(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.matchMedia("(pointer: coarse)").matches + ); +} + +function matchesCoarsePointerAtStartup(browserOrBrowsingContext) { + return SpecialPowers.spawn( + browserOrBrowsingContext, + [], + () => content.wrappedJSObject.initialMatchesCoarsePointer + ); +} + +async function isTouchEventEmitted(browserOrBrowsingContext) { + const onTimeout = wait(1000).then(() => "TIMEOUT"); + const onTouchEvent = SpecialPowers.spawn( + browserOrBrowsingContext, + [], + async () => { + content.touchStartController = new content.AbortController(); + const el = content.document.querySelector("button"); + + let gotTouchEndEvent = false; + + const promise = new Promise(resolve => { + el.addEventListener( + "touchend", + () => { + gotTouchEndEvent = true; + resolve(); + }, + { + signal: content.touchStartController.signal, + once: true, + } + ); + }); + + // For some reason, it might happen that the event is properly registered and transformed + // in the touch simulator, but not received by the event listener we set up just before. + // So here let's try to "tap" 3 times to give us more chance to catch the event. + for (let i = 0; i < 3; i++) { + if (gotTouchEndEvent) { + break; + } + + // Simulate a "tap" with mousedown and then mouseup. + EventUtils.synthesizeMouseAtCenter( + el, + { type: "mousedown", isSynthesized: false }, + content + ); + + await new Promise(res => content.setTimeout(res, 10)); + EventUtils.synthesizeMouseAtCenter( + el, + { type: "mouseup", isSynthesized: false }, + content + ); + await new Promise(res => content.setTimeout(res, 50)); + } + + return promise; + } + ); + + const result = await Promise.race([onTimeout, onTouchEvent]); + + // Remove the event listener + await SpecialPowers.spawn(browserOrBrowsingContext, [], () => { + content.touchStartController.abort(); + delete content.touchStartController; + }); + + return result !== "TIMEOUT"; +} + +async function checkTopLevelDocumentTouchSimulation({ enabled }) { + is( + await matchesCoarsePointer(gBrowser.selectedBrowser), + enabled, + `The touch simulation is ${ + enabled ? "enabled" : "disabled" + } on the top level document` + ); + + is( + await isTouchEventEmitted(gBrowser.selectedBrowser), + enabled, + `touch events are ${enabled ? "" : "not "}emitted on the top level document` + ); +} + +function topLevelDocumentMatchesCoarsePointerAtStartup() { + return matchesCoarsePointerAtStartup(gBrowser.selectedBrowser); +} + +function getIframeBrowsingContext() { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.document.querySelector("iframe").browsingContext + ); +} + +async function checkIframeTouchSimulation({ enabled }) { + const iframeBC = await getIframeBrowsingContext(); + is( + await matchesCoarsePointer(iframeBC), + enabled, + `The touch simulation is ${enabled ? "enabled" : "disabled"} on the iframe` + ); + + is( + await isTouchEventEmitted(iframeBC), + enabled, + `touch events are ${enabled ? "" : "not "}emitted on the iframe` + ); +} + +async function iframeMatchesCoarsePointerAtStartup() { + const iframeBC = await getIframeBrowsingContext(); + return matchesCoarsePointerAtStartup(iframeBC); +} diff --git a/devtools/shared/commands/target-configuration/tests/head.js b/devtools/shared/commands/target-configuration/tests/head.js new file mode 100644 index 0000000000..ce65b3d827 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/head.js @@ -0,0 +1,12 @@ +/* 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 +); diff --git a/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs b/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs new file mode 100644 index 0000000000..2b7511c788 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs @@ -0,0 +1,100 @@ +"use strict"; + +function handleRequest(request, response) { + response.setHeader("Content-Type", "html", false); + + // Check the params and set the cross-origin-opener policy headers if needed + const query = new URLSearchParams(request.queryString); + if (query.get("crossOriginIsolated") === "true") { + response.setHeader("Cross-Origin-Opener-Policy", "same-origin", false); + } + + // We always want the iframe to have a different host from the top-level document. + const iframeHost = + request.host === "example.com" ? "example.org" : "example.com"; + const iframeOrigin = `${request.scheme}://${iframeHost}`; + + const IFRAME_HTML = ` + + + + + + + + +

Iframe

+ + + `; + + const HTML = ` + + + + + test + + + + +

Test color-scheme simulation

+ + + + `; + + response.write(HTML); +} diff --git a/devtools/shared/commands/target/actions/moz.build b/devtools/shared/commands/target/actions/moz.build new file mode 100644 index 0000000000..e9429c1200 --- /dev/null +++ b/devtools/shared/commands/target/actions/moz.build @@ -0,0 +1,7 @@ +# 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/. + +DevToolsModules( + "targets.js", +) diff --git a/devtools/shared/commands/target/actions/targets.js b/devtools/shared/commands/target/actions/targets.js new file mode 100644 index 0000000000..577e5fedd3 --- /dev/null +++ b/devtools/shared/commands/target/actions/targets.js @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at . */ +"use strict"; + +function registerTarget(targetFront) { + return { type: "REGISTER_TARGET", targetFront }; +} + +function unregisterTarget(targetFront) { + return { type: "UNREGISTER_TARGET", targetFront }; +} + +/** + * + * @param {String} targetActorID: The actorID of the target we want to select. + */ +function selectTarget(targetActorID) { + return function ({ dispatch, getState }) { + dispatch({ type: "SELECT_TARGET", targetActorID }); + }; +} + +function refreshTargets() { + return { type: "REFRESH_TARGETS" }; +} + +module.exports = { + registerTarget, + unregisterTarget, + selectTarget, + refreshTargets, +}; diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js new file mode 100644 index 0000000000..e0c5b18d51 --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +class LegacyProcessesWatcher { + constructor(targetCommand, onTargetAvailable, onTargetDestroyed) { + this.targetCommand = targetCommand; + this.rootFront = targetCommand.rootFront; + + this.onTargetAvailable = onTargetAvailable; + this.onTargetDestroyed = onTargetDestroyed; + + this.descriptors = new Set(); + this._processListChanged = this._processListChanged.bind(this); + } + + async _processListChanged() { + if (this.targetCommand.isDestroyed()) { + return; + } + + const processes = await this.rootFront.listProcesses(); + // Process the new list to detect the ones being destroyed + // Force destroyed the descriptor as well as the target + for (const descriptor of this.descriptors) { + if (!processes.includes(descriptor)) { + // Manually call onTargetDestroyed listeners in order to + // ensure calling them *before* destroying the descriptor. + // Otherwise the descriptor will automatically destroy the target + // and may not fire the contentProcessTarget's destroy event. + const target = descriptor.getCachedTarget(); + if (target) { + this.onTargetDestroyed(target); + } + + descriptor.destroy(); + this.descriptors.delete(descriptor); + } + } + + const promises = processes + .filter(descriptor => !this.descriptors.has(descriptor)) + .map(async descriptor => { + // Add the new process descriptors to the local list + this.descriptors.add(descriptor); + const target = await descriptor.getTarget(); + if (!target) { + console.error( + "Wasn't able to retrieve the target for", + descriptor.actorID + ); + return; + } + await this.onTargetAvailable(target); + }); + + await Promise.all(promises); + } + + async listen() { + this.rootFront.on("processListChanged", this._processListChanged); + await this._processListChanged(); + } + + unlisten() { + this.rootFront.off("processListChanged", this._processListChanged); + } +} + +module.exports = LegacyProcessesWatcher; diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js new file mode 100644 index 0000000000..259eaea482 --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js @@ -0,0 +1,302 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + WorkersListener, + // eslint-disable-next-line mozilla/reject-some-requires +} = require("resource://devtools/client/shared/workers-listener.js"); + +const LegacyWorkersWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js"); + +class LegacyServiceWorkersWatcher extends LegacyWorkersWatcher { + // Holds the current target URL object + #currentTargetURL; + + constructor(targetCommand, onTargetAvailable, onTargetDestroyed, commands) { + super(targetCommand, onTargetAvailable, onTargetDestroyed); + this._registrations = []; + this._processTargets = new Set(); + this.commands = commands; + + // We need to listen for registration changes at least in order to properly + // filter service workers by domain when debugging a local tab. + // + // A WorkerTarget instance has a url property, but it points to the url of + // the script, whereas the url property of the ServiceWorkerRegistration + // points to the URL controlled by the service worker. + // + // Historically we have been matching the service worker registration URL + // to match service workers for local tab tools (app panel & debugger). + // Maybe here we could have some more info on the actual worker. + this._workersListener = new WorkersListener(this.rootFront, { + registrationsOnly: true, + }); + + // Note that this is called much more often than when a registration + // is created or destroyed. WorkersListener notifies of anything that + // potentially impacted workers. + // I use it as a shortcut in this first patch. Listening to rootFront's + // "serviceWorkerRegistrationListChanged" should be enough to be notified + // about registrations. And if we need to also update the + // "debuggerServiceWorkerStatus" from here, then we would have to + // also listen to "registration-changed" one each registration. + this._onRegistrationListChanged = + this._onRegistrationListChanged.bind(this); + this._onDocumentEvent = this._onDocumentEvent.bind(this); + + // Flag used from the parent class to listen to process targets. + // Decision tree is complicated, keep all logic in the parent methods. + this._isServiceWorkerWatcher = true; + } + + /** + * Override from LegacyWorkersWatcher. + * + * We record all valid service worker targets (ie workers that match a service + * worker registration), but we will only notify about the ones which match + * the current domain. + */ + _recordWorkerTarget(workerTarget) { + return !!this._getRegistrationForWorkerTarget(workerTarget); + } + + // Override from LegacyWorkersWatcher. + _supportWorkerTarget(workerTarget) { + if (!workerTarget.isServiceWorker) { + return false; + } + + const registration = this._getRegistrationForWorkerTarget(workerTarget); + return registration && this._isRegistrationValidForTarget(registration); + } + + // Override from LegacyWorkersWatcher. + async listen() { + // Listen to the current target front. + this.target = this.targetCommand.targetFront; + + if (this.targetCommand.descriptorFront.isTabDescriptor) { + this.#currentTargetURL = new URL(this.targetCommand.targetFront.url); + } + + this._workersListener.addListener(this._onRegistrationListChanged); + + // Fetch the registrations before calling listen, since service workers + // might already be available and will need to be compared with the existing + // registrations. + await this._onRegistrationListChanged(); + + if (this.targetCommand.descriptorFront.isTabDescriptor) { + await this.commands.resourceCommand.watchResources( + [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onDocumentEvent, + ignoreExistingResources: true, + } + ); + } + + await super.listen(); + } + + // Override from LegacyWorkersWatcher. + unlisten(...args) { + this._workersListener.removeListener(this._onRegistrationListChanged); + + if (this.targetCommand.descriptorFront.isTabDescriptor) { + this.commands.resourceCommand.unwatchResources( + [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT], + { + onAvailable: this._onDocumentEvent, + } + ); + } + + super.unlisten(...args); + } + + // Override from LegacyWorkersWatcher. + async _onProcessAvailable({ targetFront }) { + if (this.targetCommand.descriptorFront.isTabDescriptor) { + // XXX: This has been ported straight from the current debugger + // implementation. Since pauseMatchingServiceWorkers expects an origin + // to filter matching workers, it only makes sense when we are debugging + // a tab. However in theory, parent process debugging could pause all + // service workers without matching anything. + try { + // To support early breakpoint we need to setup the + // `pauseMatchingServiceWorkers` mechanism in each process. + await targetFront.pauseMatchingServiceWorkers({ + origin: this.#currentTargetURL.origin, + }); + } catch (e) { + if (targetFront.actorID) { + throw e; + } else { + console.warn( + "Process target destroyed while calling pauseMatchingServiceWorkers" + ); + } + } + } + + this._processTargets.add(targetFront); + return super._onProcessAvailable({ targetFront }); + } + + _onProcessDestroyed({ targetFront }) { + this._processTargets.delete(targetFront); + return super._onProcessDestroyed({ targetFront }); + } + + _onDocumentEvent(resources) { + for (const resource of resources) { + if ( + resource.resourceType !== + this.commands.resourceCommand.TYPES.DOCUMENT_EVENT + ) { + continue; + } + + if (resource.name === "will-navigate") { + // We rely on will-navigate as the onTargetAvailable for the top-level frame can + // happen after the onTargetAvailable for processes (handled in _onProcessAvailable), + // where we need the origin we navigate to. + this.#currentTargetURL = new URL(resource.newURI); + continue; + } + + // Note that we rely on "dom-loading" rather than "will-navigate" because the + // destroyed/available callbacks should be triggered after the Debugger + // has cleaned up its reducers, which happens on "will-navigate". + // On the other end, "dom-complete", which is a better mapping of "navigate", is + // happening too late (because of resources being throttled), and would cause failures + // in test (like browser_target_command_service_workers_navigation.js), as the new worker + // target would already be registered at this point, and seen as something that would + // need to be destroyed. + if (resource.name === "dom-loading") { + const allServiceWorkerTargets = this._getAllServiceWorkerTargets(); + + for (const target of allServiceWorkerTargets) { + // Note: we call isTargetRegistered again because calls to + // onTargetDestroyed might have modified the list of registered targets. + const isRegisteredAfter = + this.targetCommand.isTargetRegistered(target); + const isValidTarget = this._supportWorkerTarget(target); + if (isValidTarget && !isRegisteredAfter) { + // If the target is still valid for the current top target, call + // onTargetAvailable as well. + this.onTargetAvailable(target); + } + } + } + } + } + + async _onRegistrationListChanged() { + if (this.targetCommand.isDestroyed()) { + return; + } + + await this._updateRegistrations(); + + // Everything after this point is not strictly necessary for sw support + // in the target list, but it makes the behavior closer to the previous + // listAllWorkers/WorkersListener pair. + const allServiceWorkerTargets = this._getAllServiceWorkerTargets(); + for (const target of allServiceWorkerTargets) { + const hasRegistration = this._getRegistrationForWorkerTarget(target); + if (!hasRegistration) { + // XXX: At this point the worker target is not really destroyed, but + // historically, listAllWorkers* APIs stopped returning worker targets + // if worker registrations are no longer available. + if (this.targetCommand.isTargetRegistered(target)) { + // Only emit onTargetDestroyed if it wasn't already done by + // onNavigate (ie the target is still tracked by TargetCommand) + this.onTargetDestroyed(target); + } + // Here we only care about service workers which no longer match *any* + // registration. The worker will be completely destroyed soon, remove + // it from the legacy worker watcher internal targetsByProcess Maps. + this._removeTargetReferences(target); + } + } + } + + // Delete the provided worker target from the internal targetsByProcess Maps. + _removeTargetReferences(target) { + const allProcessTargets = this._getProcessTargets().filter(t => + this.targetsByProcess.get(t) + ); + + for (const processTarget of allProcessTargets) { + this.targetsByProcess.get(processTarget).delete(target); + } + } + + async _updateRegistrations() { + const { registrations } = + await this.rootFront.listServiceWorkerRegistrations(); + + this._registrations = registrations; + } + + _getRegistrationForWorkerTarget(workerTarget) { + return this._registrations.find(r => { + return ( + r.evaluatingWorker?.id === workerTarget.id || + r.activeWorker?.id === workerTarget.id || + r.installingWorker?.id === workerTarget.id || + r.waitingWorker?.id === workerTarget.id + ); + }); + } + + _getProcessTargets() { + return [...this._processTargets]; + } + + // Flatten all service worker targets in all processes. + _getAllServiceWorkerTargets() { + const allProcessTargets = this._getProcessTargets().filter(target => + this.targetsByProcess.get(target) + ); + + const serviceWorkerTargets = []; + for (const target of allProcessTargets) { + serviceWorkerTargets.push(...this.targetsByProcess.get(target)); + } + return serviceWorkerTargets; + } + + // Check if the registration is relevant for the current target, ie + // corresponds to the same domain. + _isRegistrationValidForTarget(registration) { + if (this.targetCommand.descriptorFront.isBrowserProcessDescriptor) { + // All registrations are valid for main process debugging. + return true; + } + + if (!this.targetCommand.descriptorFront.isTabDescriptor) { + // No support for service worker targets outside of main process & + // tab debugging. + return false; + } + + // For local tabs, we match ServiceWorkerRegistrations and the target + // if they share the same hostname for their "url" properties. + const targetDomain = this.#currentTargetURL.hostname; + try { + const registrationDomain = new URL(registration.url).hostname; + return registrationDomain === targetDomain; + } catch (e) { + // XXX: Some registrations have an empty URL. + return false; + } + } +} + +module.exports = LegacyServiceWorkersWatcher; diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js new file mode 100644 index 0000000000..b248e6aef7 --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-sharedworkers-watcher.js @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const LegacyWorkersWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js"); + +class LegacySharedWorkersWatcher extends LegacyWorkersWatcher { + // Flag used from the parent class to listen to process targets. + // Decision tree is complicated, keep all logic in the parent methods. + _isSharedWorkerWatcher = true; + + _supportWorkerTarget(workerTarget) { + return workerTarget.isSharedWorker; + } +} + +module.exports = LegacySharedWorkersWatcher; diff --git a/devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js b/devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js new file mode 100644 index 0000000000..d359d5375e --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js @@ -0,0 +1,234 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const LegacyProcessesWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-processes-watcher.js"); + +class LegacyWorkersWatcher { + constructor(targetCommand, onTargetAvailable, onTargetDestroyed) { + this.targetCommand = targetCommand; + this.rootFront = targetCommand.rootFront; + + this.onTargetAvailable = onTargetAvailable; + this.onTargetDestroyed = onTargetDestroyed; + + this.targetsByProcess = new WeakMap(); + this.targetsListeners = new WeakMap(); + + this._onProcessAvailable = this._onProcessAvailable.bind(this); + this._onProcessDestroyed = this._onProcessDestroyed.bind(this); + } + + async _onProcessAvailable({ targetFront }) { + this.targetsByProcess.set(targetFront, new Set()); + // Listen for worker which will be created later + const listener = this._workerListChanged.bind(this, targetFront); + this.targetsListeners.set(targetFront, listener); + + // If this is the browser toolbox, we have to listen from the RootFront + // (see comment in _workerListChanged) + const front = targetFront.isParentProcess ? this.rootFront : targetFront; + front.on("workerListChanged", listener); + + // We also need to process the already existing workers + await this._workerListChanged(targetFront); + } + + async _onProcessDestroyed({ targetFront }) { + const existingTargets = this.targetsByProcess.get(targetFront); + + // Process the new list to detect the ones being destroyed + // Force destroying the targets + for (const target of existingTargets) { + this.onTargetDestroyed(target); + + target.destroy(); + existingTargets.delete(target); + } + this.targetsByProcess.delete(targetFront); + this.targetsListeners.delete(targetFront); + } + + _supportWorkerTarget(workerTarget) { + // subprocess workers are ignored because they take several seconds to + // attach to when opening the browser toolbox. See bug 1594597. + // When attaching we get the following error: + // JavaScript error: resource://devtools/server/startup/worker.js, + // line 37: NetworkError: WorkerDebuggerGlobalScope.loadSubScript: Failed to load worker script at resource://devtools/shared/worker/loader.js (nsresult = 0x805e0006) + return ( + workerTarget.isDedicatedWorker && + !/resource:\/\/gre\/modules\/subprocess\/subprocess_.*\.worker\.js/.test( + workerTarget.url + ) + ); + } + + async _workerListChanged(targetFront) { + // If we're in the Browser Toolbox, query workers from the Root Front instead of the + // ParentProcessTarget as the ParentProcess Target filters out the workers to only + // show the one from the top level window, whereas we expect the one from all the + // windows, and also the window-less ones. + // TODO: For Content Toolbox, expose SW of the page, maybe optionally? + const front = targetFront.isParentProcess ? this.rootFront : targetFront; + if (!front || front.isDestroyed() || this.targetCommand.isDestroyed()) { + return; + } + + let workers; + try { + ({ workers } = await front.listWorkers()); + } catch (e) { + // Workers may be added/removed at anytime so that listWorkers request + // can be spawn during a toolbox destroy sequence and easily fail + if (front.isDestroyed()) { + return; + } + throw e; + } + + // Fetch the list of already existing worker targets for this process target front. + const existingTargets = this.targetsByProcess.get(targetFront); + if (!existingTargets) { + // unlisten was called while processing the workerListChanged callback. + return; + } + + // Process the new list to detect the ones being destroyed + // Force destroying the targets + for (const target of existingTargets) { + if (!workers.includes(target)) { + this.onTargetDestroyed(target); + + target.destroy(); + existingTargets.delete(target); + } + } + + const promises = workers.map(workerTarget => + this._processNewWorkerTarget(workerTarget, existingTargets) + ); + await Promise.all(promises); + } + + // This is overloaded for Service Workers, which records all SW targets, + // but only notify about a subset of them. + _recordWorkerTarget(workerTarget) { + return this._supportWorkerTarget(workerTarget); + } + + async _processNewWorkerTarget(workerTarget, existingTargets) { + if ( + !this._recordWorkerTarget(workerTarget) || + existingTargets.has(workerTarget) || + this.targetCommand.isDestroyed() + ) { + return; + } + + // Add the new worker targets to the local list + existingTargets.add(workerTarget); + + if (this._supportWorkerTarget(workerTarget)) { + await this.onTargetAvailable(workerTarget); + } + } + + async listen() { + // Listen to the current target front. + this.target = this.targetCommand.targetFront; + + if (this.target.isParentProcess) { + await this.targetCommand.watchTargets({ + types: [this.targetCommand.TYPES.PROCESS], + onAvailable: this._onProcessAvailable, + onDestroyed: this._onProcessDestroyed, + }); + + // The ParentProcessTarget front is considered to be a FRAME instead of a PROCESS. + // So process it manually here. + await this._onProcessAvailable({ targetFront: this.target }); + return; + } + + if (this._isSharedWorkerWatcher) { + // Here we're not in the browser toolbox, and SharedWorker targets are not supported + // in regular toolbox (See Bug 1607778) + return; + } + + if (this._isServiceWorkerWatcher) { + this._legacyProcessesWatcher = new LegacyProcessesWatcher( + this.targetCommand, + async targetFront => { + // Service workers only live in content processes. + if (!targetFront.isParentProcess) { + await this._onProcessAvailable({ targetFront }); + } + }, + targetFront => { + if (!targetFront.isParentProcess) { + this._onProcessDestroyed({ targetFront }); + } + } + ); + await this._legacyProcessesWatcher.listen(); + return; + } + + // Here, we're handling Dedicated Workers in content toolbox. + this.targetsByProcess.set( + this.target, + this.targetsByProcess.get(this.target) || new Set() + ); + this._workerListChangedListener = this._workerListChanged.bind( + this, + this.target + ); + this.target.on("workerListChanged", this._workerListChangedListener); + await this._workerListChanged(this.target); + } + + _getProcessTargets() { + return this.targetCommand.getAllTargets([this.targetCommand.TYPES.PROCESS]); + } + + unlisten({ isTargetSwitching } = {}) { + // Stop listening for new process targets. + if (this.target.isParentProcess) { + this.targetCommand.unwatchTargets({ + types: [this.targetCommand.TYPES.PROCESS], + onAvailable: this._onProcessAvailable, + onDestroyed: this._onProcessDestroyed, + }); + } else if (this._isServiceWorkerWatcher) { + this._legacyProcessesWatcher.unlisten(); + } + + // Cleanup the targetsByProcess/targetsListeners maps, and unsubscribe from + // all targetFronts. Process target fronts are either stored locally when + // watching service workers for the content toolbox, or can be retrieved via + // the TargetCommand API otherwise (see _getProcessTargets implementations). + if (this.target.isParentProcess || this._isServiceWorkerWatcher) { + for (const targetFront of this._getProcessTargets()) { + const listener = this.targetsListeners.get(targetFront); + targetFront.off("workerListChanged", listener); + + // When unlisten is called from a target switch or when we observe service workers targets + // we don't want to remove the targets from targetsByProcess + if (!isTargetSwitching || !this._isServiceWorkerWatcher) { + this.targetsByProcess.delete(targetFront); + } + this.targetsListeners.delete(targetFront); + } + } else { + this.target.off("workerListChanged", this._workerListChangedListener); + delete this._workerListChangedListener; + this.targetsByProcess.delete(this.target); + this.targetsListeners.delete(this.target); + } + } +} + +module.exports = LegacyWorkersWatcher; diff --git a/devtools/shared/commands/target/legacy-target-watchers/moz.build b/devtools/shared/commands/target/legacy-target-watchers/moz.build new file mode 100644 index 0000000000..60fdd7ec22 --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/moz.build @@ -0,0 +1,10 @@ +# 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/. + +DevToolsModules( + "legacy-processes-watcher.js", + "legacy-serviceworkers-watcher.js", + "legacy-sharedworkers-watcher.js", + "legacy-workers-watcher.js", +) diff --git a/devtools/shared/commands/target/moz.build b/devtools/shared/commands/target/moz.build new file mode 100644 index 0000000000..811fc180f0 --- /dev/null +++ b/devtools/shared/commands/target/moz.build @@ -0,0 +1,17 @@ +# 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/. + +DIRS += [ + "actions", + "legacy-target-watchers", + "reducers", + "selectors", +] + +DevToolsModules( + "target-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.toml"] diff --git a/devtools/shared/commands/target/reducers/moz.build b/devtools/shared/commands/target/reducers/moz.build new file mode 100644 index 0000000000..e9429c1200 --- /dev/null +++ b/devtools/shared/commands/target/reducers/moz.build @@ -0,0 +1,7 @@ +# 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/. + +DevToolsModules( + "targets.js", +) diff --git a/devtools/shared/commands/target/reducers/targets.js b/devtools/shared/commands/target/reducers/targets.js new file mode 100644 index 0000000000..2e93ddd7f0 --- /dev/null +++ b/devtools/shared/commands/target/reducers/targets.js @@ -0,0 +1,70 @@ +/* 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 . */ +"use strict"; + +const initialReducerState = { + // Array of targetFront + targets: [], + // The selected targetFront instance + selected: null, + // timestamp of the last time a target was updated (i.e. url/title was updated). + // This is used by the EvaluationContextSelector component to re-render the list of + // targets when the list itself did not change (no addition/removal) + lastTargetRefresh: Date.now(), +}; + +function update(state = initialReducerState, action) { + switch (action.type) { + case "SELECT_TARGET": { + const { targetActorID } = action; + + if (state.selected?.actorID === targetActorID) { + return state; + } + + const selectedTarget = state.targets.find( + target => target.actorID === targetActorID + ); + + // It's possible that the target reducer is missing a target + // e.g. workers, remote iframes, etc. (Bug 1594754) + if (!selectedTarget) { + return state; + } + + return { ...state, selected: selectedTarget }; + } + + case "REGISTER_TARGET": { + return { + ...state, + targets: [...state.targets, action.targetFront], + }; + } + + case "REFRESH_TARGETS": { + // The data _in_ targetFront was updated, so we only need to mutate the state, + // while keeping the same values. + return { + ...state, + lastTargetRefresh: Date.now(), + }; + } + + case "UNREGISTER_TARGET": { + const targets = state.targets.filter( + target => target !== action.targetFront + ); + + let { selected } = state; + if (selected === action.targetFront) { + selected = null; + } + + return { ...state, targets, selected }; + } + } + return state; +} +module.exports = update; diff --git a/devtools/shared/commands/target/selectors/moz.build b/devtools/shared/commands/target/selectors/moz.build new file mode 100644 index 0000000000..e9429c1200 --- /dev/null +++ b/devtools/shared/commands/target/selectors/moz.build @@ -0,0 +1,7 @@ +# 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/. + +DevToolsModules( + "targets.js", +) diff --git a/devtools/shared/commands/target/selectors/targets.js b/devtools/shared/commands/target/selectors/targets.js new file mode 100644 index 0000000000..95da81bbba --- /dev/null +++ b/devtools/shared/commands/target/selectors/targets.js @@ -0,0 +1,20 @@ +/* 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 . */ +"use strict"; + +function getToolboxTargets(state) { + return state.targets; +} + +function getSelectedTarget(state) { + return state.selected; +} + +function getLastTargetRefresh(state) { + return state.lastTargetRefresh; +} + +exports.getToolboxTargets = getToolboxTargets; +exports.getSelectedTarget = getSelectedTarget; +exports.getLastTargetRefresh = getLastTargetRefresh; diff --git a/devtools/shared/commands/target/target-command.js b/devtools/shared/commands/target/target-command.js new file mode 100644 index 0000000000..81b791f724 --- /dev/null +++ b/devtools/shared/commands/target/target-command.js @@ -0,0 +1,1167 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +const BROWSERTOOLBOX_SCOPE_PREF = "devtools.browsertoolbox.scope"; +// Possible values of the previous pref: +const BROWSERTOOLBOX_SCOPE_EVERYTHING = "everything"; +const BROWSERTOOLBOX_SCOPE_PARENTPROCESS = "parent-process"; + +// eslint-disable-next-line mozilla/reject-some-requires +const createStore = require("resource://devtools/client/shared/redux/create-store.js"); +const reducer = require("resource://devtools/shared/commands/target/reducers/targets.js"); + +loader.lazyRequireGetter( + this, + ["refreshTargets", "registerTarget", "unregisterTarget"], + "resource://devtools/shared/commands/target/actions/targets.js", + true +); + +class TargetCommand extends EventEmitter { + #selectedTargetFront; + /** + * This class helps managing, iterating over and listening for Targets. + * + * It exposes: + * - the top level target, typically the main process target for the browser toolbox + * or the browsing context target for a regular web toolbox + * - target of remoted iframe, in case Fission is enabled and some `); + const getLocationIdParam = url => + new URLSearchParams(new URL(url).search).get("id"); + + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const onAvailable = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + targets.push(targetFront); + }; + const destroyedTargets = []; + const onDestroyed = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + destroyedTargets.push(targetFront); + }; + + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + if (isEveryFrameTargetEnabled()) { + is( + targets.length, + 2, + "retrieved targets for top level and iframe documents" + ); + is( + targets[0], + targetCommand.targetFront, + "the target is the top level one" + ); + is( + getLocationIdParam(targets[1].url), + "iframe", + "the second target is the iframe one" + ); + } else { + is(targets.length, 1, "retrieved only the top level target"); + is( + targets[0], + targetCommand.targetFront, + "the target is the top level one" + ); + } + + is( + destroyedTargets.length, + 0, + "We get no destruction when calling watchTargets" + ); + + info("Navigate to a new page"); + let targetCountBeforeNavigation = targets.length; + const secondPageUrl = `https://example.com/document-builder.sjs?html=second`; + const onLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + secondPageUrl + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + secondPageUrl + ); + await onLoaded; + + // Same-origin navigations also spawn a new top level target + await waitFor( + () => targets.length == targetCountBeforeNavigation + 1, + "wait for the next top level target" + ); + is( + targets.at(-1), + targetCommand.targetFront, + "the new target is the top level one" + ); + + ok(targets[0].isDestroyed(), "the first target is destroyed"); + if (isEveryFrameTargetEnabled()) { + ok(targets[1].isDestroyed(), "the second target is destroyed"); + is(destroyedTargets.length, 2, "The two targets were destroyed"); + } else { + is(destroyedTargets.length, 1, "Only one target was destroyed"); + } + + // Go back to the first page, this should be a bfcache navigation, and, + // we should get a new target (or 2 if EFT is enabled) + targetCountBeforeNavigation = targets.length; + info("Go back to the first page"); + gBrowser.selectedBrowser.goBack(); + + await waitFor( + () => + targets.length === + targetCountBeforeNavigation + (isEveryFrameTargetEnabled() ? 2 : 1), + "wait for the next top level target" + ); + + if (isEveryFrameTargetEnabled()) { + await waitFor(() => targets.at(-2).url && targets.at(-1).url); + is( + getLocationIdParam(targets.at(-2).url), + "top", + "the first new target is for the top document…" + ); + is( + getLocationIdParam(targets.at(-1).url), + "iframe", + "…and the second one is for the iframe" + ); + } else { + is( + getLocationIdParam(targets.at(-1).url), + "top", + "the new target is for the first url" + ); + } + + ok( + targets[targetCountBeforeNavigation - 1].isDestroyed(), + "the target for the second page is destroyed" + ); + is( + destroyedTargets.length, + targetCountBeforeNavigation, + "We get one additional target being destroyed…" + ); + is( + destroyedTargets.at(-1), + targets[targetCountBeforeNavigation - 1], + "…and that's the second page one" + ); + + await waitForAllTargetsToBeAttached(targetCommand); + + targetCommand.unwatchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testIframeNavigations() { + info(" # Test IFRAME navigations"); + // Create a TargetCommand for a given test tab + const tab = await addTab( + `http://example.org/document-builder.sjs?html=` + ); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const onAvailable = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + targets.push(targetFront); + }; + await targetCommand.watchTargets({ types: [TYPES.FRAME], onAvailable }); + + // When fission/EFT is off, there isn't much to test for iframes as they are debugged + // when the unique top level target + if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) { + is( + targets.length, + 1, + "Without fission/EFT, there is only the top level target" + ); + await commands.destroy(); + return; + } + is(targets.length, 2, "retrieved the top level and the iframe targets"); + is( + targets[0], + targetCommand.targetFront, + "the first target is the top level one" + ); + is(targets[1].url, TEST_COM_URL, "the second target is the iframe one"); + + // Navigate to the same page with query params + info("Load the second page"); + const secondPageUrl = TEST_COM_URL + "?second-load"; + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [secondPageUrl], + function (url) { + const iframe = content.document.querySelector("iframe"); + iframe.src = url; + } + ); + + await waitFor(() => targets.length == 3, "wait for the next target"); + is(targets[2].url, secondPageUrl, "the second target is for the second url"); + ok(targets[1].isDestroyed(), "the first target is destroyed"); + + // Go back to the first page, this should be a bfcache navigation, and, + // we should get a new target + info("Go back to the first page"); + const iframeBrowsingContext = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + const iframe = content.document.querySelector("iframe"); + return iframe.browsingContext; + } + ); + await SpecialPowers.spawn(iframeBrowsingContext, [], function () { + content.history.back(); + }); + + await waitFor(() => targets.length == 4, "wait for the next target"); + is(targets[3].url, TEST_COM_URL, "the third target is for the first url"); + ok(targets[2].isDestroyed(), "the second target is destroyed"); + + // Go forward and resurect the second page, this should also be a bfcache navigation, and, + // get a new target. + info("Go forward to the second page"); + await SpecialPowers.spawn(iframeBrowsingContext, [], function () { + content.history.forward(); + }); + + await waitFor(() => targets.length == 5, "wait for the next target"); + is(targets[4].url, secondPageUrl, "the 4th target is for the second url"); + ok(targets[3].isDestroyed(), "the third target is destroyed"); + + targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable }); + + await waitForAllTargetsToBeAttached(targetCommand); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_browser_workers.js b/devtools/shared/commands/target/tests/browser_target_command_browser_workers.js new file mode 100644 index 0000000000..181cfa2614 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_browser_workers.js @@ -0,0 +1,246 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API around workers + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const WORKER_FILE = "test_worker.js"; +const CHROME_WORKER_URL = CHROME_URL_ROOT + WORKER_FILE; +const SERVICE_WORKER_URL = URL_ROOT_SSL + "test_service_worker.js"; + +add_task(async function () { + // Enabled fission's pref as the TargetCommand is almost disabled without it + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // 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 tab = await addTab(FISSION_TEST_URL); + + info("Test TargetCommand against workers via the parent process target"); + + // Instantiate a worker in the parent process + // eslint-disable-next-line no-unused-vars + const worker = new Worker(CHROME_WORKER_URL + "#simple-worker"); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker(CHROME_WORKER_URL + "#shared-worker"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + await targetCommand.startListening(); + + // Very naive sanity check against getAllTargets([workerType]) + info("Check that getAllTargets returned the expected targets"); + const workers = await targetCommand.getAllTargets([TYPES.WORKER]); + const hasWorker = workers.find(workerTarget => { + return workerTarget.url == CHROME_WORKER_URL + "#simple-worker"; + }); + ok(hasWorker, "retrieve the target for the worker"); + + const sharedWorkers = await targetCommand.getAllTargets([ + TYPES.SHARED_WORKER, + ]); + const hasSharedWorker = sharedWorkers.find(workerTarget => { + return workerTarget.url == CHROME_WORKER_URL + "#shared-worker"; + }); + ok(hasSharedWorker, "retrieve the target for the shared worker"); + + const serviceWorkers = await targetCommand.getAllTargets([ + TYPES.SERVICE_WORKER, + ]); + const hasServiceWorker = serviceWorkers.find(workerTarget => { + return workerTarget.url == SERVICE_WORKER_URL; + }); + ok(hasServiceWorker, "retrieve the target for the service worker"); + + info( + "Check that calling getAllTargets again return the same target instances" + ); + const workers2 = await targetCommand.getAllTargets([TYPES.WORKER]); + const sharedWorkers2 = await targetCommand.getAllTargets([ + TYPES.SHARED_WORKER, + ]); + const serviceWorkers2 = await targetCommand.getAllTargets([ + TYPES.SERVICE_WORKER, + ]); + is(workers2.length, workers.length, "retrieved the same number of workers"); + is( + sharedWorkers2.length, + sharedWorkers.length, + "retrieved the same number of shared workers" + ); + is( + serviceWorkers2.length, + serviceWorkers.length, + "retrieved the same number of service workers" + ); + + workers.sort(sortFronts); + workers2.sort(sortFronts); + sharedWorkers.sort(sortFronts); + sharedWorkers2.sort(sortFronts); + serviceWorkers.sort(sortFronts); + serviceWorkers2.sort(sortFronts); + + for (let i = 0; i < workers.length; i++) { + is(workers[i], workers2[i], `worker ${i} targets are the same`); + } + for (let i = 0; i < sharedWorkers2.length; i++) { + is( + sharedWorkers[i], + sharedWorkers2[i], + `shared worker ${i} targets are the same` + ); + } + for (let i = 0; i < serviceWorkers2.length; i++) { + is( + serviceWorkers[i], + serviceWorkers2[i], + `service worker ${i} targets are the same` + ); + } + + info( + "Check that watchTargets will call the create callback for all existing workers" + ); + const targets = []; + const topLevelTarget = await commands.targetCommand.targetFront; + const onAvailable = async ({ targetFront }) => { + ok( + targetFront.targetType === TYPES.WORKER || + targetFront.targetType === TYPES.SHARED_WORKER || + targetFront.targetType === TYPES.SERVICE_WORKER, + "We are only notified about worker targets" + ); + ok( + targetFront == topLevelTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER, TYPES.SERVICE_WORKER], + onAvailable, + }); + is( + targets.length, + workers.length + sharedWorkers.length + serviceWorkers.length, + "retrieved the same number of workers via watchTargets" + ); + + targets.sort(sortFronts); + const allWorkers = workers + .concat(sharedWorkers, serviceWorkers) + .sort(sortFronts); + + for (let i = 0; i < allWorkers.length; i++) { + is( + allWorkers[i], + targets[i], + `worker ${i} targets are the same via watchTargets` + ); + } + + targetCommand.unwatchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER, TYPES.SERVICE_WORKER], + onAvailable, + }); + + // Create a new worker and see if the worker target is reported + const onWorkerCreated = new Promise(resolve => { + const onAvailable2 = async ({ targetFront }) => { + if (targets.includes(targetFront)) { + return; + } + targetCommand.unwatchTargets({ + types: [TYPES.WORKER], + onAvailable: onAvailable2, + }); + resolve(targetFront); + }; + targetCommand.watchTargets({ + types: [TYPES.WORKER], + onAvailable: onAvailable2, + }); + }); + // eslint-disable-next-line no-unused-vars + const worker2 = new Worker(CHROME_WORKER_URL + "#second"); + info("Wait for the second worker to be created"); + const workerTarget = await onWorkerCreated; + + is( + workerTarget.url, + CHROME_WORKER_URL + "#second", + "This worker target is about the new worker" + ); + is( + workerTarget.name, + "test_worker.js#second", + "The worker target has the expected name" + ); + + const workers3 = await targetCommand.getAllTargets([TYPES.WORKER]); + const hasWorker2 = workers3.find( + ({ url }) => url == `${CHROME_WORKER_URL}#second` + ); + ok(hasWorker2, "retrieve the target for tab via getAllTargets"); + + info( + "Check that terminating the worker does trigger the onDestroyed callback" + ); + const onWorkerDestroyed = new Promise(resolve => { + const emptyFn = () => {}; + const onDestroyed = ({ targetFront }) => { + targetCommand.unwatchTargets({ + types: [TYPES.WORKER], + onAvailable: emptyFn, + onDestroyed, + }); + resolve(targetFront); + }; + + targetCommand.watchTargets({ + types: [TYPES.WORKER], + onAvailable: emptyFn, + onDestroyed, + }); + }); + worker2.terminate(); + const workerTargetFront = await onWorkerDestroyed; + ok(true, "onDestroyed was called when the worker was terminated"); + + workerTargetFront.isTopLevel; + ok( + true, + "isTopLevel can be called on the target front after onDestroyed was called" + ); + + workerTargetFront.name; + ok( + true, + "name can be accessed on the target front after onDestroyed was called" + ); + + targetCommand.destroy(); + + info("Unregister service workers so they don't appear in other tests."); + await unregisterAllServiceWorkers(commands.client); + + await commands.destroy(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +function sortFronts(f1, f2) { + return f1.actorID < f2.actorID; +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_detach.js b/devtools/shared/commands/target/tests/browser_target_command_detach.js new file mode 100644 index 0000000000..a0056cd7a5 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_detach.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand's when detaching the top target +// +// Do this with the "remote tab" codepath, which will avoid +// destroying the DevToolsClient when the target is destroyed. +// Otherwise, with "local tab", the client is closed and everything is destroy +// on both client and server side. + +const TEST_URL = "data:text/html,test-page"; + +add_task(async function () { + info(" ### Test detaching the top target"); + + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_URL); + + info("Create a first commands, which will destroy its top target"); + const commands = await CommandsFactory.forRemoteTab( + tab.linkedBrowser.browserId + ); + const targetCommand = commands.targetCommand; + + // We have to start listening in order to ensure having a targetFront available + await targetCommand.startListening(); + + info("Call any target front method, to ensure it works fine"); + await targetCommand.targetFront.focus(); + + // Destroying the target front should end up calling "WindowGlobalTargetActor.detach" + // which should destroy the target on the server side + await targetCommand.targetFront.destroy(); + + info( + "Now create a second commands after destroy, to see if we can spawn a new, functional target" + ); + const secondCommands = await CommandsFactory.forRemoteTab( + tab.linkedBrowser.browserId, + { + client: commands.client, + } + ); + const secondTargetCommand = secondCommands.targetCommand; + + // We have to start listening in order to ensure having a targetFront available + await secondTargetCommand.startListening(); + + info("Call any target front method, to ensure it works fine"); + await secondTargetCommand.targetFront.focus(); + + BrowserTestUtils.removeTab(tab); + + info("Close the two commands"); + await commands.destroy(); + await secondCommands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_frames.js b/devtools/shared/commands/target/tests/browser_target_command_frames.js new file mode 100644 index 0000000000..6aa0655b64 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_frames.js @@ -0,0 +1,649 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API around frames + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const IFRAME_URL = URL_ROOT_ORG_SSL + "fission_iframe.html"; +const SECOND_PAGE_URL = "https://example.org/document-builder.sjs?html=org"; + +const PID_REGEXP = /^\d+$/; + +add_task(async function () { + // Disable bfcache for Fission for now. + // If Fission is disabled, the pref is no-op. + await SpecialPowers.pushPrefEnv({ + set: [["fission.bfcacheInParent", false]], + }); + + // Enabled fission prefs + await pushPref("devtools.browsertoolbox.scope", "everything"); + // 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); + + // Test fetching the frames from the main process descriptor + await testBrowserFrames(); + + // Test fetching the frames from a tab descriptor + await testTabFrames(); + + // Test what happens with documents running in the parent process + await testOpeningOnParentProcessDocument(); + await testNavigationToParentProcessDocument(); + + // Test what happens with about:blank documents + await testOpeningOnAboutBlankDocument(); + await testNavigationToAboutBlankDocument(); + + await testNestedIframes(); +}); + +async function testOpeningOnParentProcessDocument() { + info("Test opening against a parent process document"); + const tab = await addTab("about:robots"); + is( + tab.linkedBrowser.browsingContext.currentWindowGlobal.osPid, + -1, + "The tab is loaded in the parent process" + ); + + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + is(frames.length, 1); + is(frames[0].url, "about:robots", "target url is correct"); + is( + frames[0], + targetCommand.targetFront, + "the target is the current top level one" + ); + + await commands.destroy(); +} + +async function testNavigationToParentProcessDocument() { + info("Test navigating to parent process document"); + const firstLocation = "data:text/html,foo"; + const secondLocation = "about:robots"; + + const tab = await addTab(firstLocation); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + // When the first top level target is created from the server, + // `startListening` emits a spurious switched-target event + // which isn't necessarily emited before it resolves. + // So ensure waiting for it, otherwise we may resolve too eagerly + // in our expected listener. + const onSwitchedTarget1 = targetCommand.once("switched-target"); + await targetCommand.startListening(); + info("wait for first top level target"); + await onSwitchedTarget1; + + const firstTarget = targetCommand.targetFront; + is(firstTarget.url, firstLocation, "first target url is correct"); + + info("Navigate to a parent process page"); + const onSwitchedTarget = targetCommand.once("switched-target"); + const browser = tab.linkedBrowser; + const onLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, secondLocation); + await onLoaded; + is( + browser.browsingContext.currentWindowGlobal.osPid, + -1, + "The tab is loaded in the parent process" + ); + + await onSwitchedTarget; + isnot(targetCommand.targetFront, firstTarget, "got a new target"); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + is(frames.length, 1); + is(frames[0].url, secondLocation, "second target url is correct"); + is( + frames[0], + targetCommand.targetFront, + "second target is the current top level one" + ); + + await commands.destroy(); +} + +async function testOpeningOnAboutBlankDocument() { + info("Test opening against about:blank document"); + const tab = await addTab("about:blank"); + + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + is(frames.length, 1); + is(frames[0].url, "about:blank", "target url is correct"); + is( + frames[0], + targetCommand.targetFront, + "the target is the current top level one" + ); + + await commands.destroy(); +} + +async function testNavigationToAboutBlankDocument() { + info("Test navigating to about:blank"); + const firstLocation = "data:text/html,foo"; + const secondLocation = "about:blank"; + + const tab = await addTab(firstLocation); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + // When the first top level target is created from the server, + // `startListening` emits a spurious switched-target event + // which isn't necessarily emited before it resolves. + // So ensure waiting for it, otherwise we may resolve too eagerly + // in our expected listener. + const onSwitchedTarget1 = targetCommand.once("switched-target"); + await targetCommand.startListening(); + info("wait for first top level target"); + await onSwitchedTarget1; + + const firstTarget = targetCommand.targetFront; + is(firstTarget.url, firstLocation, "first target url is correct"); + + info("Navigate to about:blank page"); + const onSwitchedTarget = targetCommand.once("switched-target"); + const browser = tab.linkedBrowser; + const onLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, secondLocation); + await onLoaded; + + await onSwitchedTarget; + isnot(targetCommand.targetFront, firstTarget, "got a new target"); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + is(frames.length, 1); + is(frames[0].url, secondLocation, "second target url is correct"); + is( + frames[0], + targetCommand.targetFront, + "second target is the current top level one" + ); + + await commands.destroy(); +} + +async function testBrowserFrames() { + info("Test TargetCommand against frames via the parent process target"); + + const aboutBlankTab = await addTab("about:blank"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + await targetCommand.startListening(); + + // Very naive sanity check against getAllTargets([frame]) + const frames = await targetCommand.getAllTargets([TYPES.FRAME]); + const hasBrowserDocument = frames.find( + frameTarget => frameTarget.url == window.location.href + ); + ok(hasBrowserDocument, "retrieve the target for the browser document"); + + const hasAboutBlankDocument = frames.find( + frameTarget => + frameTarget.browsingContextID == + aboutBlankTab.linkedBrowser.browsingContext.id + ); + ok(hasAboutBlankDocument, "retrieve the target for the about:blank tab"); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames2 = await targetCommand.getAllTargets([TYPES.FRAME]); + is(frames2.length, frames.length, "retrieved the same number of frames"); + + function sortFronts(f1, f2) { + return f1.actorID < f2.actorID; + } + frames.sort(sortFronts); + frames2.sort(sortFronts); + for (let i = 0; i < frames.length; i++) { + is(frames[i], frames2[i], `frame ${i} targets are the same`); + } + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const topLevelTarget = targetCommand.targetFront; + + const noParentTarget = await topLevelTarget.getParentTarget(); + is(noParentTarget, null, "The top level target has no parent target"); + + const onAvailable = ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == topLevelTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + ok( + PID_REGEXP.test(targetFront.processID), + `Target has processID of expected shape (${targetFront.processID})` + ); + targets.push(targetFront); + }; + await targetCommand.watchTargets({ types: [TYPES.FRAME], onAvailable }); + is( + targets.length, + frames.length, + "retrieved the same number of frames via watchTargets" + ); + + frames.sort(sortFronts); + targets.sort(sortFronts); + for (let i = 0; i < frames.length; i++) { + is( + frames[i], + targets[i], + `frame ${i} targets are the same via watchTargets` + ); + } + + async function addTabAndAssertNewTarget(url) { + const previousTargetCount = targets.length; + const tab = await addTab(url); + await waitFor( + () => targets.length == previousTargetCount + 1, + "Wait for all expected targets after tab opening" + ); + is( + targets.length, + previousTargetCount + 1, + "Opening a tab reported a new frame" + ); + const newTabTarget = targets.at(-1); + is(newTabTarget.url, url, "This frame target is about the new tab"); + // Internaly, the tab, which uses a element is considered detached from their owner document + // and so the target is having a null parentInnerWindowId. But the framework will attach all non-top-level targets + // as children of the top level. + const tabParentTarget = await newTabTarget.getParentTarget(); + is( + tabParentTarget, + targetCommand.targetFront, + "tab's WindowGlobal/BrowsingContext is detached and has no parent, but we report them as children of the top level target" + ); + + const frames3 = await targetCommand.getAllTargets([TYPES.FRAME]); + const hasTabDocument = frames3.find(target => target.url == url); + ok(hasTabDocument, "retrieve the target for tab via getAllTargets"); + + return tab; + } + + info("Open a tab loaded in content process"); + await addTabAndAssertNewTarget("data:text/html,content-process-page"); + + info("Open a tab loaded in the parent process"); + const parentProcessTab = await addTabAndAssertNewTarget("about:robots"); + is( + parentProcessTab.linkedBrowser.browsingContext.currentWindowGlobal.osPid, + -1, + "The tab is loaded in the parent process" + ); + + info("Open a new content window via window.open"); + info("First open a tab on .org domain"); + const tabUrl = "https://example.org/document-builder.sjs?html=org"; + await addTabAndAssertNewTarget(tabUrl); + const previousTargetCount = targets.length; + + info("Then open a popup on .com domain"); + const popupUrl = "https://example.com/document-builder.sjs?html=com"; + const onPopupOpened = BrowserTestUtils.waitForNewTab(gBrowser, popupUrl); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [popupUrl], async url => { + content.window.open(url, "_blank"); + }); + await onPopupOpened; + + await waitFor( + () => targets.length == previousTargetCount + 1, + "Wait for all expected targets after window.open()" + ); + is( + targets.length, + previousTargetCount + 1, + "Opening a new content window reported a new frame" + ); + is( + targets.at(-1).url, + popupUrl, + "This frame target is about the new content window" + ); + + // About:blank are a bit special because we ignore a transcient about:blank + // document when navigating to another process. But we should not ignore + // tabs, loading a real, final about:blank document. + info("Open a tab with about:blank"); + await addTabAndAssertNewTarget("about:blank"); + + // Until we start spawning target for all WindowGlobals, + // including the one running in the same process as their parent, + // we won't create dedicated target for new top level windows. + // Instead, these document will be debugged via the ParentProcessTargetActor. + info("Open a top level chrome window"); + const expectedTargets = targets.length; + const chromeWindow = Services.ww.openWindow( + null, + "about:robots", + "_blank", + "chrome", + null + ); + await wait(250); + is( + targets.length, + expectedTargets, + "New top level window shouldn't spawn new target" + ); + chromeWindow.close(); + + targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable }); + + targetCommand.destroy(); + await waitForAllTargetsToBeAttached(targetCommand); + + await commands.destroy(); +} + +async function testTabFrames(mainRoot) { + info("Test TargetCommand against frames via a tab target"); + + // Create a TargetCommand for a given test tab + const tab = await addTab(FISSION_TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetCommand.getAllTargets([TYPES.FRAME]); + // When fission is enabled, we also get the remote example.org iframe. + const expectedFramesCount = + isFissionEnabled() || isEveryFrameTargetEnabled() ? 2 : 1; + is( + frames.length, + expectedFramesCount, + "retrieved the expected number of targets" + ); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const destroyedTargets = []; + const topLevelTarget = targetCommand.targetFront; + const onAvailable = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + PID_REGEXP.test(targetFront.processID), + `Target has processID of expected shape (${targetFront.processID})` + ); + targets.push({ targetFront, isTargetSwitching }); + }; + const onDestroyed = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == topLevelTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + destroyedTargets.push({ targetFront, isTargetSwitching }); + }; + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + is( + targets.length, + frames.length, + "retrieved the same number of frames via watchTargets" + ); + is(destroyedTargets.length, 0, "Should be no destroyed target initialy"); + + for (const frame of frames) { + ok( + targets.find(({ targetFront }) => targetFront === frame), + "frame " + frame.actorID + " target is the same via watchTargets" + ); + } + is( + targets[0].targetFront.url, + FISSION_TEST_URL, + "First target should be the top document one" + ); + is( + targets[0].targetFront.isTopLevel, + true, + "First target is a top level one" + ); + is( + !targets[0].isTargetSwitching, + true, + "First target is not considered as a target switching" + ); + const noParentTarget = await targets[0].targetFront.getParentTarget(); + is(noParentTarget, null, "The top level target has no parent target"); + + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + is( + targets[1].targetFront.url, + IFRAME_URL, + "Second target should be the iframe one" + ); + is( + !targets[1].targetFront.isTopLevel, + true, + "Iframe target isn't top level" + ); + is( + !targets[1].isTargetSwitching, + true, + "Iframe target isn't a target swich" + ); + const parentTarget = await targets[1].targetFront.getParentTarget(); + is( + parentTarget, + targets[0].targetFront, + "The parent target for the iframe is the top level target" + ); + } + + // Before navigating to another process, ensure cleaning up everything from the first page + await waitForAllTargetsToBeAttached(targetCommand); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); + + info("Navigate to another domain and process (if fission is enabled)"); + // When a new target will be created, we need to wait until it's fully processed + // to avoid pending promises. + const onNewTargetProcessed = targetCommand.once("processed-available-target"); + + const browser = tab.linkedBrowser; + const onLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, SECOND_PAGE_URL); + await onLoaded; + + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + const afterNavigationFramesCount = 3; + await waitFor( + () => targets.length == afterNavigationFramesCount, + "Wait for all expected targets after navigation" + ); + is( + targets.length, + afterNavigationFramesCount, + "retrieved all targets after navigation" + ); + // As targetFront.url isn't reliable and might be about:blank, + // try to assert that we got the right target via other means. + // outerWindowID should change when navigating to another process, + // while it would stay equal for in-process navigations. + is( + targets[2].targetFront.outerWindowID, + browser.outerWindowID, + "The new target should be the newly loaded document" + ); + is( + targets[2].isTargetSwitching, + true, + "and should be flagged as a target switching" + ); + + is( + destroyedTargets.length, + 2, + "The two existing targets should be destroyed" + ); + is( + destroyedTargets[0].targetFront, + targets[1].targetFront, + "The first destroyed should be the iframe one" + ); + is( + destroyedTargets[0].isTargetSwitching, + false, + "the target destruction is not flagged as target switching for iframes" + ); + is( + destroyedTargets[1].targetFront, + targets[0].targetFront, + "The second destroyed should be the previous top level one (because it is delayed to be fired *after* will-navigate)" + ); + is( + destroyedTargets[1].isTargetSwitching, + true, + "the target destruction is flagged as target switching" + ); + } else { + await waitFor( + () => targets.length == 2, + "Wait for all expected targets after navigation" + ); + is( + destroyedTargets.length, + 1, + "with JSWindowActor based target, the top level target is destroyed" + ); + is( + targetCommand.targetFront, + targets[1].targetFront, + "we got a new target" + ); + ok( + !targetCommand.targetFront.isDestroyed(), + "that target is not destroyed" + ); + ok( + targets[0].targetFront.isDestroyed(), + "but the previous one is destroyed" + ); + } + + await onNewTargetProcessed; + + targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable }); + + targetCommand.destroy(); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testNestedIframes() { + info("Test TargetCommand against nested frames"); + + const nestedIframeUrl = `https://example.com/document-builder.sjs?html=${encodeURIComponent( + "second

second level iframe

" + )}&delay=500`; + + const testUrl = `data:text/html;charset=utf-8, +

Top-level

+ `; + + // Create a TargetCommand for a given test tab + const tab = await addTab(testUrl); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Check that calling getAllTargets([frame]) return the same target instances + const frames = await targetCommand.getAllTargets([TYPES.FRAME]); + + is(frames[0], targetCommand.targetFront, "First target is the top level one"); + const topParent = await frames[0].getParentTarget(); + is(topParent, null, "Top level target has no parent"); + + if (isEveryFrameTargetEnabled()) { + const firstIframeTarget = frames.find(target => target.title == "first"); + ok( + firstIframeTarget, + "With EFT, got the target for the first level iframe" + ); + const firstParent = await firstIframeTarget.getParentTarget(); + is( + firstParent, + targetCommand.targetFront, + "With EFT, first level has top level target as parent" + ); + + const secondIframeTarget = frames.find(target => target.title == "second"); + ok(secondIframeTarget, "Got the target for the second level iframe"); + const secondParent = await secondIframeTarget.getParentTarget(); + is( + secondParent, + firstIframeTarget, + "With EFT, second level has the first level target as parent" + ); + } else if (isFissionEnabled()) { + const secondIframeTarget = frames.find(target => target.title == "second"); + ok(secondIframeTarget, "Got the target for the second level iframe"); + const secondParent = await secondIframeTarget.getParentTarget(); + is( + secondParent, + targetCommand.targetFront, + "With fission, second level has top level target as parent" + ); + } + + await commands.destroy(); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_frames_popups.js b/devtools/shared/commands/target/tests/browser_target_command_frames_popups.js new file mode 100644 index 0000000000..68f7244671 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_frames_popups.js @@ -0,0 +1,168 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that we create targets for popups + +const TEST_URL = "https://example.org/document-builder.sjs?html=main page"; +const POPUP_URL = "https://example.com/document-builder.sjs?html=popup"; +const POPUP_SECOND_URL = + "https://example.com/document-builder.sjs?html=popup-navigated"; + +add_task(async function () { + await pushPref("devtools.popups.debug", true); + // We expect to create a target for a same-process iframe + // in the test against window.open to load a document in an iframe. + await pushPref("devtools.every-frame-target.enabled", true); + + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const destroyedTargets = []; + const onAvailable = ({ targetFront }) => { + targets.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + destroyedTargets.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + is(targets.length, 1, "At first, we only get one target"); + is( + targets[0], + targetCommand.targetFront, + "And this target is the top level one" + ); + + info("Open a popup"); + const firstPopupBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [POPUP_URL], + url => { + const win = content.open(url); + return win.browsingContext; + } + ); + + await waitFor(() => targets.length === 2); + ok(true, "We are notified about the first popup's target"); + + is( + targets[1].browsingContextID, + firstPopupBrowsingContext.id, + "the new target is for the popup" + ); + is(targets[1].url, POPUP_URL, "the new target has the right url"); + + info("Navigate the popup to a second location"); + await SpecialPowers.spawn( + firstPopupBrowsingContext, + [POPUP_SECOND_URL], + url => { + content.location.href = url; + } + ); + + await waitFor(() => targets.length === 3); + ok(true, "We are notified about the new location popup's target"); + + await waitFor(() => destroyedTargets.length === 1); + ok(true, "The first popup's target is destroyed"); + is( + destroyedTargets[0], + targets[1], + "The destroyed target is the popup's one" + ); + + is( + targets[2].browsingContextID, + firstPopupBrowsingContext.id, + "the new location target is for the popup" + ); + is( + targets[2].url, + POPUP_SECOND_URL, + "the new location target has the right url" + ); + + info("Close the popup"); + await SpecialPowers.spawn(firstPopupBrowsingContext, [], () => { + content.close(); + }); + + await waitFor(() => destroyedTargets.length === 2); + ok(true, "The popup's target is destroyed"); + is( + destroyedTargets[1], + targets[2], + "The destroyed target is the popup's one" + ); + + info("Open a about:blank popup"); + const aboutBlankPopupBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => { + const win = content.open("about:blank"); + return win.browsingContext; + } + ); + + await waitFor(() => targets.length === 4); + ok(true, "We are notified about the about:blank popup's target"); + + is( + targets[3].browsingContextID, + aboutBlankPopupBrowsingContext.id, + "the new target is for the popup" + ); + is(targets[3].url, "about:blank", "the new target has the right url"); + + info("Select the original tab and reload it"); + gBrowser.selectedTab = tab; + await BrowserTestUtils.reloadTab(tab); + + await waitFor(() => targets.length === 5); + is(targets[4], targetCommand.targetFront, "We get a new top level target"); + ok(!targets[3].isDestroyed(), "The about:blank popup target is still alive"); + + info("Call about:blank popup method to ensure it really is functional"); + await targets[3].logInPage("foo"); + + info( + "Ensure that iframe using window.open to load their document aren't considered as popups" + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const iframe = content.document.createElement("iframe"); + iframe.setAttribute("name", "test-iframe"); + content.document.documentElement.appendChild(iframe); + content.open("data:text/html,iframe", "test-iframe"); + }); + await waitFor(() => targets.length === 6); + is( + targets[5].targetForm.isPopup, + false, + "The iframe target isn't considered as a popup" + ); + + targetCommand.unwatchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + targetCommand.destroy(); + BrowserTestUtils.removeTab(tab); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.js b/devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.js new file mode 100644 index 0000000000..d05ff5a962 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_frames_reload_server_side_targets.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 framework handles reloading a document with multiple remote frames (See Bug 1724909). + +const REMOTE_ORIGIN = "https://example.com/"; +const REMOTE_IFRAME_URL_1 = + REMOTE_ORIGIN + "/document-builder.sjs?html=first_remote_iframe"; +const REMOTE_IFRAME_URL_2 = + REMOTE_ORIGIN + "/document-builder.sjs?html=second_remote_iframe"; +const TEST_URL = + "https://example.org/document-builder.sjs?html=org" + + `` + + ``; + +add_task(async function () { + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Assert that watchTargets will call the create callback for all existing frames + const targets = []; + const destroyedTargets = []; + const onAvailable = ({ targetFront }) => { + targets.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + destroyedTargets.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + + await waitFor(() => targets.length === 3); + ok( + true, + "We are notified about the top-level document and the 2 remote iframes" + ); + + info("Reload the page"); + // When a new target will be created, we need to wait until it's fully processed + // to avoid pending promises. + const onNewTargetProcessed = targetCommand.once("processed-available-target"); + gBrowser.reloadTab(tab); + await onNewTargetProcessed; + + await waitFor(() => targets.length === 6 && destroyedTargets.length === 3); + + // Get the previous targets in a dedicated array and remove them from `targets` + const previousTargets = targets.splice(0, 3); + ok( + previousTargets.every(targetFront => targetFront.isDestroyed()), + "The previous targets are all destroyed" + ); + ok( + targets.every(targetFront => !targetFront.isDestroyed()), + "The new targets are not destroyed" + ); + + info("Reload one of the iframe"); + SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const iframeEl = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframeEl.browsingContext, [], () => { + content.document.location.reload(); + }); + }); + await waitFor( + () => + targets.length + previousTargets.length === 7 && + destroyedTargets.length === 4 + ); + const iframeTarget = targets.find(t => t === destroyedTargets.at(-1)); + ok(iframeTarget, "Got the iframe target that got destroyed"); + for (const target of targets) { + if (target == iframeTarget) { + ok( + target.isDestroyed(), + "The iframe target we navigated from is destroyed" + ); + } else { + ok( + !target.isDestroyed(), + `Target ${target.actorID}|${target.url} isn't destroyed` + ); + } + } + + targetCommand.unwatchTargets({ + types: [TYPES.FRAME], + onAvailable, + onDestroyed, + }); + targetCommand.destroy(); + BrowserTestUtils.removeTab(tab); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js b/devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js new file mode 100644 index 0000000000..a7d5e51b3c --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_getAllTargets.js @@ -0,0 +1,119 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API getAllTargets. + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const CHROME_WORKER_URL = CHROME_URL_ROOT + "test_worker.js"; + +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); + + info("Setup the test page with workers of all types"); + + const tab = await addTab(FISSION_TEST_URL); + + // Instantiate a worker in the parent process + // eslint-disable-next-line no-unused-vars + const worker = new Worker(CHROME_WORKER_URL + "#simple-worker"); + // eslint-disable-next-line no-unused-vars + const sharedWorker = new SharedWorker(CHROME_WORKER_URL + "#shared-worker"); + + info("Create a target list for the main process target"); + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + await targetCommand.startListening(); + + info("Check getAllTargets will throw when providing invalid arguments"); + Assert.throws( + () => targetCommand.getAllTargets(), + e => e.message === "getAllTargets expects a non-empty array of types" + ); + + Assert.throws( + () => targetCommand.getAllTargets([]), + e => e.message === "getAllTargets expects a non-empty array of types" + ); + + info("Check getAllTargets returns consistent results with several types"); + const workerTargets = targetCommand.getAllTargets([TYPES.WORKER]); + const serviceWorkerTargets = targetCommand.getAllTargets([ + TYPES.SERVICE_WORKER, + ]); + const sharedWorkerTargets = targetCommand.getAllTargets([ + TYPES.SHARED_WORKER, + ]); + const processTargets = targetCommand.getAllTargets([TYPES.PROCESS]); + const frameTargets = targetCommand.getAllTargets([TYPES.FRAME]); + + const allWorkerTargetsReference = [ + ...workerTargets, + ...serviceWorkerTargets, + ...sharedWorkerTargets, + ]; + const allWorkerTargets = targetCommand.getAllTargets([ + TYPES.WORKER, + TYPES.SERVICE_WORKER, + TYPES.SHARED_WORKER, + ]); + + is( + allWorkerTargets.length, + allWorkerTargetsReference.length, + "getAllTargets([worker, service, shared]) returned the expected number of targets" + ); + + ok( + allWorkerTargets.every(t => allWorkerTargetsReference.includes(t)), + "getAllTargets([worker, service, shared]) returned the expected targets" + ); + + const allTargetsReference = [ + ...allWorkerTargets, + ...processTargets, + ...frameTargets, + ]; + const allTargets = targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is( + allTargets.length, + allTargetsReference.length, + "getAllTargets(ALL_TYPES) returned the expected number of targets" + ); + + ok( + allTargets.every(t => allTargetsReference.includes(t)), + "getAllTargets(ALL_TYPES) returned the expected targets" + ); + + for (const target of allTargets) { + is( + target.commands, + commands, + "Each target front has a `commands` attribute - " + target + ); + } + + // Wait for all the targets to be fully attached so we don't have pending requests. + await waitForAllTargetsToBeAttached(targetCommand); + + ok( + !targetCommand.isDestroyed(), + "TargetCommand isn't destroyed before calling commands.destroy()" + ); + await commands.destroy(); + ok( + targetCommand.isDestroyed(), + "TargetCommand is destroyed after calling commands.destroy()" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js b/devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js new file mode 100644 index 0000000000..dbdaae7f05 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_invalid_api_usage.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test watch/unwatchTargets throw when provided with invalid types. + +const TEST_URL = "data:text/html;charset=utf-8,invalid api usage test"; + +add_task(async function () { + info("Setup the test page with workers of all types"); + const tab = await addTab(TEST_URL); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + const onAvailable = function () {}; + + await Assert.rejects( + targetCommand.watchTargets({ types: [null], onAvailable }), + /TargetCommand.watchTargets invoked with an unknown type/, + "watchTargets should throw for null type" + ); + + await Assert.rejects( + targetCommand.watchTargets({ types: [undefined], onAvailable }), + /TargetCommand.watchTargets invoked with an unknown type/, + "watchTargets should throw for undefined type" + ); + + await Assert.rejects( + targetCommand.watchTargets({ types: ["NOT_A_TARGET"], onAvailable }), + /TargetCommand.watchTargets invoked with an unknown type/, + "watchTargets should throw for unknown type" + ); + + await Assert.rejects( + targetCommand.watchTargets({ + types: [targetCommand.TYPES.FRAME, "NOT_A_TARGET"], + onAvailable, + }), + /TargetCommand.watchTargets invoked with an unknown type/, + "watchTargets should throw for unknown type mixed with a correct type" + ); + + Assert.throws( + () => targetCommand.unwatchTargets({ types: [null], onAvailable }), + /TargetCommand.unwatchTargets invoked with an unknown type/, + "unwatchTargets should throw for null type" + ); + + Assert.throws( + () => targetCommand.unwatchTargets({ types: [undefined], onAvailable }), + /TargetCommand.unwatchTargets invoked with an unknown type/, + "unwatchTargets should throw for undefined type" + ); + + Assert.throws( + () => + targetCommand.unwatchTargets({ types: ["NOT_A_TARGET"], onAvailable }), + /TargetCommand.unwatchTargets invoked with an unknown type/, + "unwatchTargets should throw for unknown type" + ); + + Assert.throws( + () => + targetCommand.unwatchTargets({ + types: [targetCommand.TYPES.CONSOLE_MESSAGE, "NOT_A_TARGET"], + onAvailable, + }), + /TargetCommand.unwatchTargets invoked with an unknown type/, + "unwatchTargets should throw for unknown type mixed with a correct type" + ); + + BrowserTestUtils.removeTab(tab); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_processes.js b/devtools/shared/commands/target/tests/browser_target_command_processes.js new file mode 100644 index 0000000000..d4f57ae036 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_processes.js @@ -0,0 +1,242 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API around processes + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`
`); + +add_task(async function () { + // Enabled fission's pref as the TargetCommand is almost disabled without it + await pushPref("devtools.browsertoolbox.scope", "everything"); + // Disable the preloaded process as it gets created lazily and may interfere + // with process count assertions + await pushPref("dom.ipc.processPrelaunch.enabled", false); + // This preference helps destroying the content process when we close the tab + await pushPref("dom.ipc.keepProcessesAlive.web", 1); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + await testProcesses(targetCommand, targetCommand.targetFront); + + targetCommand.destroy(); + // Wait for all the targets to be fully attached so we don't have pending requests. + await Promise.all( + targetCommand.getAllTargets(targetCommand.ALL_TYPES).map(t => t.initialized) + ); + + await commands.destroy(); +}); + +add_task(async function () { + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const created = []; + const destroyed = []; + const onAvailable = ({ targetFront }) => { + created.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + destroyed.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [targetCommand.TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + Assert.greater(created.length, 1, "We get many content process targets"); + + targetCommand.stopListening(); + + await waitFor( + () => created.length == destroyed.length, + "Wait for the destruction of all content process targets when calling stopListening" + ); + is( + created.length, + destroyed.length, + "Got notification of destruction for all previously reported targets" + ); + + targetCommand.destroy(); + // Wait for all the targets to be fully attached so we don't have pending requests. + await Promise.all( + targetCommand.getAllTargets(targetCommand.ALL_TYPES).map(t => t.initialized) + ); + + await commands.destroy(); +}); + +async function testProcesses(targetCommand, target) { + info("Test TargetCommand against processes"); + const { TYPES } = targetCommand; + + // Note that ppmm also includes the parent process, which is considered as a frame rather than a process + const originalProcessesCount = Services.ppmm.childCount - 1; + const processes = await targetCommand.getAllTargets([TYPES.PROCESS]); + is( + processes.length, + originalProcessesCount, + "Get a target for all content processes" + ); + + const processes2 = await targetCommand.getAllTargets([TYPES.PROCESS]); + is( + processes2.length, + originalProcessesCount, + "retrieved the same number of processes" + ); + function sortFronts(f1, f2) { + return f1.actorID < f2.actorID; + } + processes.sort(sortFronts); + processes2.sort(sortFronts); + for (let i = 0; i < processes.length; i++) { + is(processes[i], processes2[i], `process ${i} targets are the same`); + } + + // Assert that watchTargets will call the create callback for all existing frames + const targets = new Set(); + + const pidRegExp = /^\d+$/; + + const onAvailable = ({ targetFront }) => { + if (targets.has(targetFront)) { + ok(false, "The same target is notified multiple times via onAvailable"); + } + is( + targetFront.targetType, + TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + targetFront == target ? targetFront.isTopLevel : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + ok( + pidRegExp.test(targetFront.processID), + `Target has processID of expected shape (${targetFront.processID})` + ); + targets.add(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + if (!targets.has(targetFront)) { + ok( + false, + "A target is declared destroyed via onDestroy without being notified via onAvailable" + ); + } + is( + targetFront.targetType, + TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + !targetFront.isTopLevel, + "We are never notified about the top level target destruction" + ); + targets.delete(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + is( + targets.size, + originalProcessesCount, + "retrieved the same number of processes via watchTargets" + ); + for (let i = 0; i < processes.length; i++) { + ok( + targets.has(processes[i]), + `process ${i} targets are the same via watchTargets` + ); + } + + const previousTargets = new Set(targets); + // Assert that onAvailable is called for processes created *after* the call to watchTargets + const onProcessCreated = new Promise(resolve => { + const onAvailable2 = ({ targetFront }) => { + if (previousTargets.has(targetFront)) { + return; + } + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable2, + }); + resolve(targetFront); + }; + targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable2, + }); + }); + const tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + const createdTarget = await onProcessCreated; + // For some reason, creating a new tab purges processes created from previous tests + // so it is not reasonable to assert the size of `targets` as it may be lower than expected. + ok(targets.has(createdTarget), "The new tab process is in the list"); + + const processCountAfterTabOpen = targets.size; + + // Assert that onDestroy is called for destroyed processes + const onProcessDestroyed = new Promise(resolve => { + const onAvailable3 = () => {}; + const onDestroyed3 = ({ targetFront }) => { + resolve(targetFront); + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable3, + onDestroyed: onDestroyed3, + }); + }; + targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable3, + onDestroyed: onDestroyed3, + }); + }); + + BrowserTestUtils.removeTab(tab1); + + const destroyedTarget = await onProcessDestroyed; + is( + targets.size, + processCountAfterTabOpen - 1, + "The closed tab's process has been reported as destroyed" + ); + ok( + !targets.has(destroyedTarget), + "The destroyed target is no longer in the list" + ); + is( + destroyedTarget, + createdTarget, + "The destroyed target is the one that has been reported as created" + ); + + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + + // Ensure that getAllTargets still works after the call to unwatchTargets + const processes3 = await targetCommand.getAllTargets([TYPES.PROCESS]); + is( + processes3.length, + processCountAfterTabOpen - 1, + "getAllTargets reports a new target" + ); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_reload.js b/devtools/shared/commands/target/tests/browser_target_command_reload.js new file mode 100644 index 0000000000..9d8cacd23d --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_reload.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand's reload method +// +// Note that we reload against main process, +// but this is hard/impossible to test as it reloads the test script itself +// and so stops its execution. + +// Load a page with a JS script that change its value everytime we load it +// (that's to see if the reload loads from cache or not) +const TEST_URL = URL_ROOT + "incremental-js-value-script.sjs"; + +add_task(async function () { + info(" ### Test reloading a Tab"); + + // Create a TargetCommand for a given test tab + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + // We have to start listening in order to ensure having a targetFront available + await targetCommand.startListening(); + + const firstJSValue = await getContentVariable(); + is(firstJSValue, "1", "Got an initial value for the JS variable"); + + const onReloaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await targetCommand.reloadTopLevelTarget(); + info("Wait for the tab to be reloaded"); + await onReloaded; + + const secondJSValue = await getContentVariable(); + is( + secondJSValue, + "1", + "The first reload didn't bypass the cache, so the JS Script is the same and we got the same value" + ); + + const onSecondReloaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + await targetCommand.reloadTopLevelTarget(true); + info("Wait for the tab to be reloaded"); + await onSecondReloaded; + + // The value is 3 and not 2, because we got a HTTP request, but it returned 304 and the browser fetched his cached content + const thirdJSValue = await getContentVariable(); + is( + thirdJSValue, + "3", + "The second reload did bypass the cache, so the JS Script is different and we got a new value" + ); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +}); + +add_task(async function () { + info(" ### Test reloading an Add-on"); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background() { + const { browser } = this; + browser.test.log("background script executed"); + }, + }); + + await extension.startup(); + + const commands = await CommandsFactory.forAddon(extension.id); + const targetCommand = commands.targetCommand; + + // We have to start listening in order to ensure having a targetFront available + await targetCommand.startListening(); + + const { onResource: onReloaded } = + await commands.resourceCommand.waitForNextResource( + commands.resourceCommand.TYPES.DOCUMENT_EVENT, + { + ignoreExistingResources: true, + predicate(resource) { + return resource.name == "dom-loading"; + }, + } + ); + + const backgroundPageURL = targetCommand.targetFront.url; + ok(backgroundPageURL, "Got the background page URL"); + await targetCommand.reloadTopLevelTarget(); + + info("Wait for next dom-loading DOCUMENT_EVENT"); + const event = await onReloaded; + + // If we get about:blank here, it most likely means we receive notification + // for the previous background page being unload and navigating to about:blank + is( + event.url, + backgroundPageURL, + "We received the DOCUMENT_EVENT's for the expected document: the new background page." + ); + + await commands.destroy(); + + await extension.unload(); +}); +function getContentVariable() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + return content.wrappedJSObject.jsValue; + }); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_scope_flag.js b/devtools/shared/commands/target/tests/browser_target_command_scope_flag.js new file mode 100644 index 0000000000..65d9e9a622 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_scope_flag.js @@ -0,0 +1,190 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API with changes made to devtools.browsertoolbox.scope + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`
`); + +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()) { + 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); + + // First test 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 destroyedTargetIsModeSwitchingMap = new Map(); + const onAvailable = async ({ targetFront }) => { + targets.add(targetFront); + }; + const onDestroyed = ({ targetFront, isModeSwitching }) => { + destroyedTargetIsModeSwitchingMap.set(targetFront, isModeSwitching); + targets.delete(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.PROCESS, TYPES.FRAME], + onAvailable, + onDestroyed, + }); + Assert.greater(targets.size, 1, "We get many targets"); + + info("Open a tab in a new content process"); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + + const newTabProcessID = + gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal + .osPid; + const newTabInnerWindowId = + gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal + .innerWindowId; + + info("Wait for the tab content process target"); + const processTarget = await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.PROCESS && + target.processID == newTabProcessID + ) + ); + + info("Wait for the tab window global target"); + const windowGlobalTarget = await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.FRAME && + target.innerWindowId == newTabInnerWindowId + ) + ); + + let multiprocessTargetCount = targets.size; + + info("Disable multiprocess debugging"); + await pushPref("devtools.browsertoolbox.scope", "parent-process"); + + info("Wait for all targets but top level and workers to be destroyed"); + await waitFor(() => + [...targets].every( + target => + target == targetCommand.targetFront || target.targetType == TYPES.WORKER + ) + ); + + ok(processTarget.isDestroyed(), "The process target is destroyed"); + ok( + destroyedTargetIsModeSwitchingMap.get(processTarget), + "isModeSwitching was passed to onTargetDestroyed and is true for the process target" + ); + ok(windowGlobalTarget.isDestroyed(), "The window global target is destroyed"); + ok( + destroyedTargetIsModeSwitchingMap.get(windowGlobalTarget), + "isModeSwitching was passed to onTargetDestroyed and is true for the window global target" + ); + + info("Open a second tab in a new content process"); + const parentProcessTargetCount = targets.size; + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + + await wait(1000); + is( + parentProcessTargetCount, + targets.size, + "The new tab process should be ignored and no target be created" + ); + + info("Re-enable multiprocess debugging"); + await pushPref("devtools.browsertoolbox.scope", "everything"); + + // The second tab relates to one content process target and one window global target + multiprocessTargetCount += 2; + + await waitFor( + () => targets.size == multiprocessTargetCount, + "Wait for all targets we used to have before disable multiprocess debugging" + ); + + info("Wait for the tab content process target to be available again"); + ok( + [...targets].some( + target => + target.targetType == TYPES.PROCESS && + target.processID == newTabProcessID + ), + "We have the tab content process target" + ); + + info("Wait for the tab window global target to be available again"); + ok( + [...targets].some( + target => + target.targetType == TYPES.FRAME && + target.innerWindowId == newTabInnerWindowId + ), + "We have the tab window global target" + ); + + info("Open a third tab in a new content process"); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + + const thirdTabProcessID = + gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal + .osPid; + const thirdTabInnerWindowId = + gBrowser.selectedTab.linkedBrowser.browsingContext.currentWindowGlobal + .innerWindowId; + + info("Wait for the third tab content process target"); + await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.PROCESS && + target.processID == thirdTabProcessID + ) + ); + + info("Wait for the third tab window global target"); + await waitFor(() => + [...targets].find( + target => + target.targetType == TYPES.FRAME && + target.innerWindowId == thirdTabInnerWindowId + ) + ); + + targetCommand.destroy(); + + // Wait for all the targets to be fully attached so we don't have pending requests. + await Promise.all( + targetCommand.getAllTargets(targetCommand.ALL_TYPES).map(t => t.initialized) + ); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_service_workers.js b/devtools/shared/commands/target/tests/browser_target_command_service_workers.js new file mode 100644 index 0000000000..d71401fd8c --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_service_workers.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API for service workers in content tabs. + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.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); + + info("Setup the test page with workers of all types"); + + const tab = await addTab(FISSION_TEST_URL); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + // Enable Service Worker listening. + targetCommand.listenForServiceWorkers = true; + await targetCommand.startListening(); + + const serviceWorkerTargets = targetCommand.getAllTargets([ + TYPES.SERVICE_WORKER, + ]); + is( + serviceWorkerTargets.length, + 1, + "TargetCommmand has 1 service worker target" + ); + + info("Check that the onAvailable is done when watchTargets resolves"); + const targets = []; + const onAvailable = async ({ targetFront }) => { + // Wait for one second here to check that watch targets waits for + // the onAvailable callbacks correctly. + await wait(1000); + targets.push(targetFront); + }; + const onDestroyed = ({ targetFront }) => + targets.splice(targets.indexOf(targetFront), 1); + + await targetCommand.watchTargets({ + types: [TYPES.SERVICE_WORKER], + onAvailable, + onDestroyed, + }); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + is(targets.length, 1, "onAvailable has resolved"); + is( + targets[0], + serviceWorkerTargets[0], + "onAvailable was called with the expected service worker target" + ); + + info("Unregister the worker and wait until onDestroyed is called."); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); + await waitUntil(() => targets.length === 0); + + // Stop listening to avoid worker related requests + targetCommand.destroy(); + + await commands.waitForRequestsToSettle(); + + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js b/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js new file mode 100644 index 0000000000..7bf6c856c2 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js @@ -0,0 +1,358 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API for service workers when navigating in content tabs. +// When the top level target navigates, we manually call onTargetAvailable for +// service workers which now match the page domain. We assert that the callbacks +// will be called the expected number of times here. + +const COM_PAGE_URL = URL_ROOT_SSL + "test_sw_page.html"; +const COM_WORKER_URL = URL_ROOT_SSL + "test_sw_page_worker.js"; +const ORG_PAGE_URL = URL_ROOT_ORG_SSL + "test_sw_page.html"; +const ORG_WORKER_URL = URL_ROOT_ORG_SSL + "test_sw_page_worker.js"; + +/** + * This test will navigate between two pages, both controlled by different + * service workers. + * + * The steps will be: + * - navigate to .com page + * - create target list + * -> onAvailable should be called for the .com worker + * - navigate to .org page + * -> onAvailable should be called for the .org worker + * - reload .org page + * -> nothing should happen + * - unregister .org worker + * -> onDestroyed should be called for the .org worker + * - navigate back to .com page + * -> nothing should happen + * - unregister .com worker + * -> onDestroyed should be called for the .com worker + */ +add_task(async function test_NavigationBetweenTwoDomains_NoDestroy() { + await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + const { hooks, commands, targetCommand } = await watchServiceWorkerTargets( + tab + ); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [COM_WORKER_URL], + }); + + info("Go to .org page, wait for onAvailable to be called"); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + ORG_PAGE_URL + ); + await checkHooks(hooks, { + available: 2, + destroyed: 0, + targets: [COM_WORKER_URL, ORG_WORKER_URL], + }); + + info("Reload .org page, onAvailable and onDestroyed should not be called"); + await BrowserTestUtils.reloadTab(gBrowser.selectedTab); + await checkHooks(hooks, { + available: 2, + destroyed: 0, + targets: [COM_WORKER_URL, ORG_WORKER_URL], + }); + + info("Unregister .org service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(ORG_WORKER_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [COM_WORKER_URL], + }); + + info("Go back to .com page"); + const onBrowserLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + COM_PAGE_URL + ); + await onBrowserLoaded; + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [COM_WORKER_URL], + }); + + info("Unregister .com service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(COM_WORKER_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 2, + targets: [], + }); + + // Stop listening to avoid worker related requests + targetCommand.destroy(); + + await commands.waitForRequestsToSettle(); + await commands.destroy(); + await removeTab(tab); +}); + +/** + * In this test we load a service worker in a page prior to starting the + * TargetCommand. We start the target list on another page, and then we go back to + * the first page. We want to check that we are correctly notified about the + * worker that was spawned before TargetCommand. + * + * Steps: + * - navigate to .com page + * - navigate to .org page + * - create target list + * -> onAvailable is called for the .org worker + * - unregister .org worker + * -> onDestroyed is called for the .org worker + * - navigate back to .com page + * -> onAvailable is called for the .com worker + * - unregister .com worker + * -> onDestroyed is called for the .com worker + */ +add_task(async function test_NavigationToPageWithExistingWorker() { + await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + info("Wait until the service worker registration is registered"); + await waitForRegistrationReady(tab, COM_PAGE_URL, COM_WORKER_URL); + + info("Navigate to another page"); + let onBrowserLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + ORG_PAGE_URL + ); + + // Avoid TV failures, where target list still starts thinking that the + // current domain is .com . + info("Wait until we have fully navigated to the .org page"); + // wait for the browser to be loaded otherwise the task spawned in waitForRegistrationReady + // might be destroyed (when it still belongs to the previous content process) + await onBrowserLoaded; + await waitForRegistrationReady(tab, ORG_PAGE_URL, ORG_WORKER_URL); + + const { hooks, commands, targetCommand } = await watchServiceWorkerTargets( + tab + ); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [ORG_WORKER_URL], + }); + + info("Unregister .org service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(ORG_WORKER_URL); + await checkHooks(hooks, { + available: 1, + destroyed: 1, + targets: [], + }); + + info("Go back .com page, wait for onAvailable to be called"); + onBrowserLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + COM_PAGE_URL + ); + await onBrowserLoaded; + + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [COM_WORKER_URL], + }); + + info("Unregister .com service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(COM_WORKER_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 2, + targets: [], + }); + + // Stop listening to avoid worker related requests + targetCommand.destroy(); + + await commands.waitForRequestsToSettle(); + await commands.destroy(); + await removeTab(tab); +}); + +add_task(async function test_NavigationToPageWithExistingStoppedWorker() { + await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + info("Wait until the service worker registration is registered"); + await waitForRegistrationReady(tab, COM_PAGE_URL, COM_WORKER_URL); + + await stopServiceWorker(COM_WORKER_URL); + + const { hooks, commands, targetCommand } = await watchServiceWorkerTargets( + tab + ); + + // Let some time to watch target to eventually regress and revive the worker + await wait(1000); + + // As the Service Worker doesn't have any active worker... it doesn't report any target. + info( + "Verify that no SW is reported after it has been stopped and we start watching for service workers" + ); + await checkHooks(hooks, { + available: 0, + destroyed: 0, + targets: [], + }); + + info("Reload the worker module via the postMessage call"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const registration = await content.wrappedJSObject.registrationPromise; + // Force loading the worker again, even it has been stopped + registration.active.postMessage(""); + }); + + info("Verify that the SW is notified"); + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [COM_WORKER_URL], + }); + + await unregisterServiceWorker(COM_WORKER_URL); + + await checkHooks(hooks, { + available: 1, + destroyed: 1, + targets: [], + }); + + // Stop listening to avoid worker related requests + targetCommand.destroy(); + + await commands.waitForRequestsToSettle(); + await commands.destroy(); + await removeTab(tab); +}); + +async function setupServiceWorkerNavigationTest() { + // 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); +} + +async function watchServiceWorkerTargets(tab) { + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + // Enable Service Worker listening. + targetCommand.listenForServiceWorkers = true; + await targetCommand.startListening(); + + // Setup onAvailable & onDestroyed callbacks so that we can check how many + // times they are called and with which targetFront. + const hooks = { + availableCount: 0, + destroyedCount: 0, + targets: [], + }; + + const onAvailable = async ({ targetFront }) => { + info(` + Service worker target available for ${targetFront.url}\n`); + hooks.availableCount++; + hooks.targets.push(targetFront); + }; + + const onDestroyed = ({ targetFront }) => { + info(` - Service worker target destroy for ${targetFront.url}\n`); + hooks.destroyedCount++; + hooks.targets.splice(hooks.targets.indexOf(targetFront), 1); + }; + + await targetCommand.watchTargets({ + types: [targetCommand.TYPES.SERVICE_WORKER], + onAvailable, + onDestroyed, + }); + + return { hooks, commands, targetCommand }; +} + +/** + * Wait until the expected URL is loaded and win.registration has resolved. + */ +async function waitForRegistrationReady(tab, expectedPageUrl, workerUrl) { + await asyncWaitUntil(() => + SpecialPowers.spawn(tab.linkedBrowser, [expectedPageUrl], function (_url) { + try { + const win = content.wrappedJSObject; + const isExpectedUrl = win.location.href === _url; + const hasRegistration = !!win.registrationPromise; + return isExpectedUrl && hasRegistration; + } catch (e) { + return false; + } + }) + ); + // On debug builds, the registration may not be yet ready in the parent process + // so we also need to ensure it is ready. + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + await waitFor(() => { + // Unfortunately we can't use swm.getRegistrationByPrincipal, as it requires a "scope", which doesn't seem to be the worker URL. + const registrations = swm.getAllRegistrations(); + for (let i = 0; i < registrations.length; i++) { + const info = registrations.queryElementAt( + i, + Ci.nsIServiceWorkerRegistrationInfo + ); + // Lookup for an exact URL match. + if (info.scriptSpec === workerUrl) { + return true; + } + } + return false; + }); +} + +/** + * Assert helper for the `hooks` object, updated by the onAvailable and + * onDestroyed callbacks. Assert that the callbacks have been called the + * expected number of times, with the expected targets. + */ +async function checkHooks(hooks, { available, destroyed, targets }) { + await waitUntil( + () => hooks.availableCount == available && hooks.destroyedCount == destroyed + ); + is(hooks.availableCount, available, "onAvailable was called as expected"); + is(hooks.destroyedCount, destroyed, "onDestroyed was called as expected"); + + is(hooks.targets.length, targets.length, "Expected number of targets"); + targets.forEach((url, i) => { + is(hooks.targets[i].url, url, `SW target ${i} has the expected url`); + }); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js b/devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js new file mode 100644 index 0000000000..04646117a9 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_switchToTarget.js @@ -0,0 +1,138 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API switchToTarget function + +add_task(async function testSwitchToTarget() { + info("Test TargetCommand.switchToTarget method"); + + // Create a first target to switch from, a new tab with an iframe + const firstTab = await addTab( + `data:text/html,` + ); + const commands = await CommandsFactory.forTab(firstTab); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + await targetCommand.startListening(); + + // Create a second target to switch to, a new tab with an iframe + const secondTab = await addTab( + `data:text/html,` + ); + // We have to spawn a new distinct `commands` object for this new tab, + // but we will otherwise consider the first one as the main one. + // From this second one, we will only retrieve a new target. + const secondCommands = await CommandsFactory.forTab(secondTab, { + client: commands.client, + }); + await secondCommands.targetCommand.startListening(); + const secondTarget = secondCommands.targetCommand.targetFront; + + const frameTargets = []; + const firstTarget = targetCommand.targetFront; + let currentTarget = targetCommand.targetFront; + const onFrameAvailable = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "We are only notified about frame targets" + ); + ok( + targetFront == currentTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + if (targetFront.isTopLevel) { + // When calling watchTargets, this will be false, but it will be true when calling switchToTarget + is( + isTargetSwitching, + currentTarget == secondTarget, + "target switching boolean is correct" + ); + } else { + ok(!isTargetSwitching, "for now, only top level target can be switched"); + } + frameTargets.push(targetFront); + }; + const destroyedTargets = []; + const onFrameDestroyed = ({ targetFront, isTargetSwitching }) => { + is( + targetFront.targetType, + TYPES.FRAME, + "target-destroyed: We are only notified about frame targets" + ); + ok( + targetFront == firstTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "target-destroyed: isTopLevel property is correct" + ); + if (targetFront.isTopLevel) { + is( + isTargetSwitching, + true, + "target-destroyed: target switching boolean is correct" + ); + } else { + ok( + !isTargetSwitching, + "target-destroyed: for now, only top level target can be switched" + ); + } + destroyedTargets.push(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.FRAME], + onAvailable: onFrameAvailable, + onDestroyed: onFrameDestroyed, + }); + + // Save the original list of targets + const createdTargets = [...frameTargets]; + // Clear the recorded target list of all existing targets + frameTargets.length = 0; + + currentTarget = secondTarget; + await targetCommand.switchToTarget(secondTarget); + + is( + targetCommand.targetFront, + currentTarget, + "After the switch, the top level target has been updated" + ); + // Because JS Window Actor API isn't used yet, FrameDescriptor.getTarget returns null + // And there is no target being created for the iframe, yet. + // As soon as bug 1565200 is resolved, this should return two frames, including the iframe. + is( + frameTargets.length, + 1, + "We get the report of the top level iframe when switching to the new target" + ); + is(frameTargets[0], currentTarget); + //is(frameTargets[1].url, "data:text/html,foo"); + + // Ensure that all the targets reported before the call to switchToTarget + // are reported as destroyed while calling switchToTarget. + is( + destroyedTargets.length, + createdTargets.length, + "All targets original reported are destroyed" + ); + for (const newTarget of createdTargets) { + ok( + destroyedTargets.includes(newTarget), + "Each originally target is reported as destroyed" + ); + } + + targetCommand.destroy(); + + await commands.destroy(); + await secondCommands.destroy(); + + BrowserTestUtils.removeTab(firstTab); + BrowserTestUtils.removeTab(secondTab); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_tab_workers.js b/devtools/shared/commands/target/tests/browser_target_command_tab_workers.js new file mode 100644 index 0000000000..92f5629d4c --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_tab_workers.js @@ -0,0 +1,322 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API around workers + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const IFRAME_FILE = "fission_iframe.html"; +const REMOTE_IFRAME_URL = URL_ROOT_ORG_SSL + IFRAME_FILE; +const IFRAME_URL = URL_ROOT_SSL + IFRAME_FILE; +const WORKER_FILE = "test_worker.js"; +const WORKER_URL = URL_ROOT_SSL + WORKER_FILE; +const REMOTE_IFRAME_WORKER_URL = URL_ROOT_ORG_SSL + WORKER_FILE; + +add_task(async function () { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // The WorkerDebuggerManager#getWorkerDebuggerEnumerator method we're using to retrieve + // workers loops through _all_ the workers in the process, which means it goes over workers + // from other tabs as well. Here we add a few tabs that are not going to be used in the + // test, just to check that their workers won't be retrieved by getAllTargets/watchTargets. + await addTab(`${FISSION_TEST_URL}?id=first-untargetted-tab&noServiceWorker`); + await addTab(`${FISSION_TEST_URL}?id=second-untargetted-tab&noServiceWorker`); + + info("Test TargetCommand against workers via a tab target"); + const tab = await addTab(`${FISSION_TEST_URL}?&noServiceWorker`); + + // Create a TargetCommand for the tab + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + // Workaround to allow listening for workers in the content toolbox + // without the fission preferences + targetCommand.listenForWorkers = true; + + await commands.targetCommand.startListening(); + + const { TYPES } = targetCommand; + + info("Check that getAllTargets only returns dedicated workers"); + const workers = await targetCommand.getAllTargets([ + TYPES.WORKER, + TYPES.SHARED_WORKER, + ]); + + // XXX: This should be modified in Bug 1607778, where we plan to add support for shared workers. + is(workers.length, 2, "Retrieved two worker…"); + const mainPageWorker = workers.find( + worker => worker.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorker = workers.find(worker => { + return worker.url == `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-iframe`; + }); + ok(mainPageWorker, "…the dedicated worker on the main page"); + ok(iframeWorker, "…and the dedicated worker on the iframe"); + + info( + "Assert that watchTargets will call the create callback for existing dedicated workers" + ); + const targets = []; + const destroyedTargets = []; + const onAvailable = async ({ targetFront }) => { + info(`onAvailable called for ${targetFront.url}`); + is( + targetFront.targetType, + TYPES.WORKER, + "We are only notified about worker targets" + ); + ok(!targetFront.isTopLevel, "The workers are never top level"); + targets.push(targetFront); + info(`Handled ${targets.length} targets\n`); + }; + const onDestroyed = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.WORKER, + "We are only notified about worker targets" + ); + ok(!targetFront.isTopLevel, "The workers are never top level"); + destroyedTargets.push(targetFront); + }; + + await targetCommand.watchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroyed, + }); + + // XXX: This should be modified in Bug 1607778, where we plan to add support for shared workers. + info("Check that watched targets return the same fronts as getAllTargets"); + is(targets.length, 2, "watcheTargets retrieved 2 worker…"); + const mainPageWorkerTarget = targets.find(t => t === mainPageWorker); + const iframeWorkerTarget = targets.find(t => t === iframeWorker); + + ok( + mainPageWorkerTarget, + "…the dedicated worker in main page, which is the same front we received from getAllTargets" + ); + ok( + iframeWorkerTarget, + "…the dedicated worker in iframe, which is the same front we received from getAllTargets" + ); + + info("Spawn workers in main page and iframe"); + await SpecialPowers.spawn(tab.linkedBrowser, [WORKER_FILE], workerUrl => { + // Put the worker on the global so we can access it later + content.spawnedWorker = new content.Worker(`${workerUrl}#spawned-worker`); + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [workerUrl], innerWorkerUrl => { + // Put the worker on the global so we can access it later + content.spawnedWorker = new content.Worker( + `${innerWorkerUrl}#spawned-worker-in-iframe` + ); + }); + }); + + await waitFor( + () => targets.length === 4, + "Wait for the target list to notify us about the spawned worker" + ); + const mainPageSpawnedWorkerTarget = targets.find( + innerTarget => innerTarget.url == `${WORKER_URL}#spawned-worker` + ); + ok(mainPageSpawnedWorkerTarget, "Retrieved spawned worker"); + const iframeSpawnedWorkerTarget = targets.find( + innerTarget => + innerTarget.url == `${REMOTE_IFRAME_WORKER_URL}#spawned-worker-in-iframe` + ); + ok(iframeSpawnedWorkerTarget, "Retrieved spawned worker in iframe"); + + await wait(100); + + info( + "Check that the target list calls onDestroy when a worker is terminated" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.spawnedWorker.terminate(); + content.spawnedWorker = null; + + SpecialPowers.spawn(content.document.querySelector("iframe"), [], () => { + content.spawnedWorker.terminate(); + content.spawnedWorker = null; + }); + }); + await waitFor( + () => + destroyedTargets.includes(mainPageSpawnedWorkerTarget) && + destroyedTargets.includes(iframeSpawnedWorkerTarget), + "Wait for the target list to notify us about the terminated workers" + ); + + ok( + true, + "The target list handled the terminated workers (from the main page and the iframe)" + ); + + info( + "Check that reloading the page will notify about the terminated worker and the new existing one" + ); + const targetsCountBeforeReload = targets.length; + await reloadBrowser(); + + await waitFor(() => { + return ( + destroyedTargets.includes(mainPageWorkerTarget) && + destroyedTargets.includes(iframeWorkerTarget) + ); + }, `Wait for the target list to notify us about the terminated workers when reloading`); + ok( + true, + "The target list notified us about all the expected workers being destroyed when reloading" + ); + + await waitFor( + () => targets.length === targetsCountBeforeReload + 2, + "Wait for the target list to notify us about the new workers after reloading" + ); + + const mainPageWorkerTargetAfterReload = targets.find( + t => t !== mainPageWorkerTarget && t.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorkerTargetAfterReload = targets.find( + t => + t !== iframeWorkerTarget && + t.url == `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-iframe` + ); + + ok( + mainPageWorkerTargetAfterReload, + "The target list handled the worker created once the page navigated" + ); + ok( + iframeWorkerTargetAfterReload, + "The target list handled the worker created in the iframe once the page navigated" + ); + + const targetCount = targets.length; + + info( + "Check that when removing an iframe we're notified about its workers being terminated" + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.document.querySelector("iframe").remove(); + }); + await waitFor(() => { + return destroyedTargets.includes(iframeWorkerTargetAfterReload); + }, `Wait for the target list to notify us about the terminated workers when removing an iframe`); + + info("Check that target list handles adding iframes with workers"); + const iframeUrl = `${IFRAME_URL}?noServiceWorker=true&hashSuffix=in-created-iframe`; + const remoteIframeUrl = `${REMOTE_IFRAME_URL}?noServiceWorker=true&hashSuffix=in-created-remote-iframe`; + + await SpecialPowers.spawn( + tab.linkedBrowser, + [iframeUrl, remoteIframeUrl], + (url, remoteUrl) => { + const firstIframe = content.document.createElement("iframe"); + content.document.body.append(firstIframe); + firstIframe.src = url + "-1"; + + const secondIframe = content.document.createElement("iframe"); + content.document.body.append(secondIframe); + secondIframe.src = url + "-2"; + + const firstRemoteIframe = content.document.createElement("iframe"); + content.document.body.append(firstRemoteIframe); + firstRemoteIframe.src = remoteUrl + "-1"; + + const secondRemoteIframe = content.document.createElement("iframe"); + content.document.body.append(secondRemoteIframe); + secondRemoteIframe.src = remoteUrl + "-2"; + } + ); + + // It's important to check the length of `targets` here to ensure we don't get unwanted + // worker targets. + await waitFor( + () => targets.length === targetCount + 4, + "Wait for the target list to notify us about the workers in the new iframes" + ); + const firstSpawnedIframeWorkerTarget = targets.find( + worker => worker.url == `${WORKER_URL}#simple-worker-in-created-iframe-1` + ); + const secondSpawnedIframeWorkerTarget = targets.find( + worker => worker.url == `${WORKER_URL}#simple-worker-in-created-iframe-2` + ); + const firstSpawnedRemoteIframeWorkerTarget = targets.find( + worker => + worker.url == + `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-created-remote-iframe-1` + ); + const secondSpawnedRemoteIframeWorkerTarget = targets.find( + worker => + worker.url == + `${REMOTE_IFRAME_WORKER_URL}#simple-worker-in-created-remote-iframe-2` + ); + + ok( + firstSpawnedIframeWorkerTarget, + "The target list handled the worker in the first new same-origin iframe" + ); + ok( + secondSpawnedIframeWorkerTarget, + "The target list handled the worker in the second new same-origin iframe" + ); + ok( + firstSpawnedRemoteIframeWorkerTarget, + "The target list handled the worker in the first new remote iframe" + ); + ok( + secondSpawnedRemoteIframeWorkerTarget, + "The target list handled the worker in the second new remote iframe" + ); + + info("Check that navigating away does destroy all targets"); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "data:text/html,Away" + ); + + await waitFor( + () => destroyedTargets.length === targets.length, + "Wait for all the targets to be reported as destroyed" + ); + + ok( + destroyedTargets.includes(mainPageWorkerTargetAfterReload), + "main page worker target was destroyed" + ); + ok( + destroyedTargets.includes(firstSpawnedIframeWorkerTarget), + "first spawned same-origin iframe worker target was destroyed" + ); + ok( + destroyedTargets.includes(secondSpawnedIframeWorkerTarget), + "second spawned same-origin iframe worker target was destroyed" + ); + ok( + destroyedTargets.includes(firstSpawnedRemoteIframeWorkerTarget), + "first spawned remote iframe worker target was destroyed" + ); + ok( + destroyedTargets.includes(secondSpawnedRemoteIframeWorkerTarget), + "second spawned remote iframe worker target was destroyed" + ); + + targetCommand.unwatchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroyed, + }); + targetCommand.destroy(); + + info("Unregister service workers so they don't appear in other tests."); + await unregisterAllServiceWorkers(commands.client); + + BrowserTestUtils.removeTab(tab); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js b/devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js new file mode 100644 index 0000000000..e628f827e2 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_tab_workers_bfcache_navigation.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test WORKER targets when doing history navigations (BF Cache) +// +// Use a distinct file as this test currently hits a DEBUG assertion +// https://searchfox.org/mozilla-central/rev/352b525ab841278cd9b3098343f655ef85933544/dom/workers/WorkerPrivate.cpp#5218 +// and so is running only on OPT builds. + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const WORKER_FILE = "test_worker.js"; +const WORKER_URL = URL_ROOT_SSL + WORKER_FILE; +const IFRAME_WORKER_URL = URL_ROOT_ORG_SSL + WORKER_FILE; + +add_task(async function () { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // The WorkerDebuggerManager#getWorkerDebuggerEnumerator method we're using to retrieve + // workers loops through _all_ the workers in the process, which means it goes over workers + // from other tabs as well. Here we add a few tabs that are not going to be used in the + // test, just to check that their workers won't be retrieved by getAllTargets/watchTargets. + await addTab(`${FISSION_TEST_URL}?id=first-untargetted-tab&noServiceWorker`); + await addTab(`${FISSION_TEST_URL}?id=second-untargetted-tab&noServiceWorker`); + + info("Test bfcache navigations"); + const tab = await addTab(`${FISSION_TEST_URL}?&noServiceWorker`); + + // Create a TargetCommand for the tab + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + + // Workaround to allow listening for workers in the content toolbox + // without the fission preferences + targetCommand.listenForWorkers = true; + + await targetCommand.startListening(); + + const { TYPES } = targetCommand; + + info( + "Assert that watchTargets will call the onAvailable callback for existing dedicated workers" + ); + const targets = []; + const destroyedTargets = []; + const onAvailable = async ({ targetFront }) => { + info(`onAvailable called for ${targetFront.url}`); + is( + targetFront.targetType, + TYPES.WORKER, + "We are only notified about worker targets" + ); + ok(!targetFront.isTopLevel, "The workers are never top level"); + targets.push(targetFront); + info(`Handled ${targets.length} new targets`); + }; + const onDestroyed = async ({ targetFront }) => { + is( + targetFront.targetType, + TYPES.WORKER, + "We are only notified about worker targets" + ); + ok(!targetFront.isTopLevel, "The workers are never top level"); + destroyedTargets.push(targetFront); + }; + + await targetCommand.watchTargets({ + types: [TYPES.WORKER, TYPES.SHARED_WORKER], + onAvailable, + onDestroyed, + }); + + is(targets.length, 2, "watchTargets retrieved 2 workers…"); + const mainPageWorkerTarget = targets.find( + worker => worker.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorkerTarget = targets.find( + worker => worker.url == `${IFRAME_WORKER_URL}#simple-worker-in-iframe` + ); + + ok( + mainPageWorkerTarget, + "…the dedicated worker in main page, which is the same front we received from getAllTargets" + ); + ok( + iframeWorkerTarget, + "…the dedicated worker in iframe, which is the same front we received from getAllTargets" + ); + + info("Check that navigating away does destroy all targets"); + const onBrowserLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "data:text/html,Away" + ); + await onBrowserLoaded; + + await waitFor( + () => destroyedTargets.length === 2, + "Wait for all the targets to be reported as destroyed" + ); + + info("Navigate back to the first page"); + gBrowser.goBack(); + + await waitFor( + () => targets.length === 4, + "Wait for the target list to notify us about the first page workers, restored from the BF Cache" + ); + + const mainPageWorkerTargetAfterGoingBack = targets.find( + t => t !== mainPageWorkerTarget && t.url == `${WORKER_URL}#simple-worker` + ); + const iframeWorkerTargetAfterGoingBack = targets.find( + t => + t !== iframeWorkerTarget && + t.url == `${IFRAME_WORKER_URL}#simple-worker-in-iframe` + ); + + ok( + mainPageWorkerTargetAfterGoingBack, + "The target list handled the worker created from the BF Cache" + ); + ok( + iframeWorkerTargetAfterGoingBack, + "The target list handled the worker created in the iframe from the BF Cache" + ); + + targetCommand.destroy(); + await commands.destroy(); +}); diff --git a/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js b/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js new file mode 100644 index 0000000000..4ee5dd8b2f --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js @@ -0,0 +1,284 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand API with all possible descriptors + +const TEST_URL = "https://example.org/document-builder.sjs?html=org"; +const SECOND_TEST_URL = "https://example.com/document-builder.sjs?html=org"; +const CHROME_WORKER_URL = CHROME_URL_ROOT + "test_worker.js"; + +const DESCRIPTOR_TYPES = require("resource://devtools/client/fronts/descriptors/descriptor-types.js"); + +add_task(async function () { + // Enabled fission prefs + await pushPref("devtools.browsertoolbox.scope", "everything"); + // 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); + + await testLocalTab(); + await testRemoteTab(); + await testParentProcess(); + await testWorker(); + await testWebExtension(); +}); + +async function testParentProcess() { + info("Test TargetCommand against parent process descriptor"); + + const commands = await CommandsFactory.forMainProcess(); + const { descriptorFront } = commands; + + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.PROCESS, + "The descriptor type is correct" + ); + is( + descriptorFront.isParentProcessDescriptor, + true, + "Descriptor front isParentProcessDescriptor is correct" + ); + is( + descriptorFront.isProcessDescriptor, + true, + "Descriptor front isProcessDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + Assert.greater( + targets.length, + 1, + "We get many targets when debugging the parent process" + ); + const targetFront = targets[0]; + is(targetFront, targetCommand.targetFront, "The first is the top level one"); + is( + targetFront.targetType, + targetCommand.TYPES.FRAME, + "the parent process target is of frame type, because it inherits from WindowGlobalTargetActor" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + targetCommand.destroy(); + await waitForAllTargetsToBeAttached(targetCommand); + + await commands.destroy(); +} + +async function testLocalTab() { + info("Test TargetCommand against local tab descriptor (via getTab({ tab }))"); + + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forTab(tab); + const { descriptorFront } = commands; + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.TAB, + "The descriptor type is correct" + ); + is( + descriptorFront.isTabDescriptor, + true, + "Descriptor front isTabDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is(targets.length, 1, "Got a unique target"); + const targetFront = targets[0]; + is(targetFront, targetCommand.targetFront, "The first is the top level one"); + is( + targetFront.targetType, + targetCommand.TYPES.FRAME, + "the tab target is of frame type" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + targetCommand.destroy(); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testRemoteTab() { + info( + "Test TargetCommand against remote tab descriptor (via getTab({ browserId }))" + ); + + const tab = await addTab(TEST_URL); + const commands = await CommandsFactory.forRemoteTab( + tab.linkedBrowser.browserId + ); + const { descriptorFront } = commands; + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.TAB, + "The descriptor type is correct" + ); + is( + descriptorFront.isTabDescriptor, + true, + "Descriptor front isTabDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is(targets.length, 1, "Got a unique target"); + const targetFront = targets[0]; + is( + targetFront, + targetCommand.targetFront, + "TargetCommand top target is the same as the first target" + ); + is( + targetFront.targetType, + targetCommand.TYPES.FRAME, + "the tab target is of frame type" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + const browser = tab.linkedBrowser; + const onLoaded = BrowserTestUtils.browserLoaded(browser); + BrowserTestUtils.startLoadingURIString(browser, SECOND_TEST_URL); + await onLoaded; + + info("Wait for the new target"); + await waitFor(() => targetCommand.targetFront != targetFront); + isnot( + targetCommand.targetFront, + targetFront, + "The top level target changes on navigation" + ); + ok( + !targetCommand.targetFront.isDestroyed(), + "The new target isn't destroyed" + ); + ok(targetFront.isDestroyed(), "While the previous target is destroyed"); + + targetCommand.destroy(); + + BrowserTestUtils.removeTab(tab); + + await commands.destroy(); +} + +async function testWebExtension() { + info("Test TargetCommand against webextension descriptor"); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + name: "Sample extension", + }, + }); + + await extension.startup(); + + const commands = await CommandsFactory.forAddon(extension.id); + const { descriptorFront } = commands; + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.EXTENSION, + "The descriptor type is correct" + ); + is( + descriptorFront.isWebExtensionDescriptor, + true, + "Descriptor front isWebExtensionDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is(targets.length, 1, "Got a unique target"); + const targetFront = targets[0]; + is(targetFront, targetCommand.targetFront, "The first is the top level one"); + is( + targetFront.targetType, + targetCommand.TYPES.FRAME, + "the web extension target is of frame type, because it inherits from WindowGlobalTargetActor" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + targetCommand.destroy(); + + await extension.unload(); + + await commands.destroy(); +} + +// CommandsFactory expect the worker id, which is computed from the nsIWorkerDebugger.id attribute +function getNextWorkerDebuggerId() { + return new Promise(resolve => { + const wdm = Cc[ + "@mozilla.org/dom/workers/workerdebuggermanager;1" + ].createInstance(Ci.nsIWorkerDebuggerManager); + const listener = { + onRegister(dbg) { + wdm.removeListener(listener); + resolve(dbg.id); + }, + }; + wdm.addListener(listener); + }); +} +async function testWorker() { + info("Test TargetCommand against worker descriptor"); + + const workerUrl = CHROME_WORKER_URL + "#descriptor"; + const onNextWorker = getNextWorkerDebuggerId(); + const worker = new Worker(workerUrl); + const workerId = await onNextWorker; + ok(workerId, "Found the worker Debugger ID"); + + const commands = await CommandsFactory.forWorker(workerId); + const { descriptorFront } = commands; + is( + descriptorFront.descriptorType, + DESCRIPTOR_TYPES.WORKER, + "The descriptor type is correct" + ); + is( + descriptorFront.isWorkerDescriptor, + true, + "Descriptor front isWorkerDescriptor is correct" + ); + + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const targets = await targetCommand.getAllTargets(targetCommand.ALL_TYPES); + is(targets.length, 1, "Got a unique target"); + const targetFront = targets[0]; + is(targetFront, targetCommand.targetFront, "The first is the top level one"); + is( + targetFront.targetType, + targetCommand.TYPES.WORKER, + "the worker target is of worker type" + ); + is(targetFront.isTopLevel, true, "This is flagged as top level"); + + targetCommand.destroy(); + + // Calling CommandsFactory.forWorker, will call RootFront.getWorker + // which will spawn lots of worker legacy code, firing lots of requests, + // which may still be pending + await commands.waitForRequestsToSettle(); + + await commands.destroy(); + worker.terminate(); +} diff --git a/devtools/shared/commands/target/tests/browser_target_command_watchTargets.js b/devtools/shared/commands/target/tests/browser_target_command_watchTargets.js new file mode 100644 index 0000000000..516780be01 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_watchTargets.js @@ -0,0 +1,214 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the TargetCommand's `watchTargets` function + +const TEST_URL = + "data:text/html;charset=utf-8," + encodeURIComponent(`
`); + +add_task(async function () { + // Enabled fission's pref as the TargetCommand is almost disabled without it + await pushPref("devtools.browsertoolbox.scope", "everything"); + // 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); + + await testWatchTargets(); + await testThrowingInOnAvailable(); +}); + +async function testWatchTargets() { + info("Test TargetCommand watchTargets function"); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Note that ppmm also includes the parent process, which is considered as a frame rather than a process + const originalProcessesCount = Services.ppmm.childCount - 1; + + info( + "Check that onAvailable is called for processes already created *before* the call to watchTargets" + ); + const targets = new Set(); + const topLevelTarget = targetCommand.targetFront; + const onAvailable = ({ targetFront }) => { + if (targets.has(targetFront)) { + ok(false, "The same target is notified multiple times via onAvailable"); + } + is( + targetFront.targetType, + TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + targetFront == topLevelTarget + ? targetFront.isTopLevel + : !targetFront.isTopLevel, + "isTopLevel property is correct" + ); + targets.add(targetFront); + }; + const onDestroyed = ({ targetFront }) => { + if (!targets.has(targetFront)) { + ok( + false, + "A target is declared destroyed via onDestroyed without being notified via onAvailable" + ); + } + is( + targetFront.targetType, + TYPES.PROCESS, + "We are only notified about process targets" + ); + ok( + !targetFront.isTopLevel, + "We are not notified about the top level target destruction" + ); + targets.delete(targetFront); + }; + await targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + is( + targets.size, + originalProcessesCount, + "retrieved the expected number of processes via watchTargets" + ); + // Start from 1 in order to ignore the parent process target, which is considered as a frame rather than a process + for (let i = 1; i < Services.ppmm.childCount; i++) { + const process = Services.ppmm.getChildAt(i); + const hasTargetWithSamePID = [...targets].find( + processTarget => processTarget.targetForm.processID == process.osPid + ); + ok( + hasTargetWithSamePID, + `Process with PID ${process.osPid} has been reported via onAvailable` + ); + } + + info( + "Check that onAvailable is called for processes created *after* the call to watchTargets" + ); + const previousTargets = new Set(targets); + const onProcessCreated = new Promise(resolve => { + const onAvailable2 = ({ targetFront }) => { + if (previousTargets.has(targetFront)) { + return; + } + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable2, + }); + resolve(targetFront); + }; + targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable2, + }); + }); + const tab1 = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: TEST_URL, + forceNewProcess: true, + }); + const createdTarget = await onProcessCreated; + + // For some reason, creating a new tab purges processes created from previous tests + // so it is not reasonable to assert the side of `targets` as it may be lower than expected. + ok(targets.has(createdTarget), "The new tab process is in the list"); + + const processCountAfterTabOpen = targets.size; + + // Assert that onDestroyed is called for destroyed processes + const onProcessDestroyed = new Promise(resolve => { + const onAvailable3 = () => {}; + const onDestroyed3 = ({ targetFront }) => { + resolve(targetFront); + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable3, + onDestroyed: onDestroyed3, + }); + }; + targetCommand.watchTargets({ + types: [TYPES.PROCESS], + onAvailable: onAvailable3, + onDestroyed: onDestroyed3, + }); + }); + + BrowserTestUtils.removeTab(tab1); + + const destroyedTarget = await onProcessDestroyed; + is( + targets.size, + processCountAfterTabOpen - 1, + "The closed tab's process has been reported as destroyed" + ); + ok( + !targets.has(destroyedTarget), + "The destroyed target is no longer in the list" + ); + is( + destroyedTarget, + createdTarget, + "The destroyed target is the one that has been reported as created" + ); + + targetCommand.unwatchTargets({ + types: [TYPES.PROCESS], + onAvailable, + onDestroyed, + }); + + targetCommand.destroy(); + + await commands.destroy(); +} + +async function testThrowingInOnAvailable() { + info( + "Test TargetCommand watchTargets function when an exception is thrown in onAvailable callback" + ); + + const commands = await CommandsFactory.forMainProcess(); + const targetCommand = commands.targetCommand; + const { TYPES } = targetCommand; + + await targetCommand.startListening(); + + // Note that ppmm also includes the parent process, which is considered as a frame rather than a process + const originalProcessesCount = Services.ppmm.childCount - 1; + + info( + "Check that onAvailable is called for processes already created *before* the call to watchTargets" + ); + const targets = new Set(); + let thrown = false; + const onAvailable = ({ targetFront }) => { + if (!thrown) { + thrown = true; + throw new Error("Force an exception when processing the first target"); + } + targets.add(targetFront); + }; + await targetCommand.watchTargets({ types: [TYPES.PROCESS], onAvailable }); + is( + targets.size, + originalProcessesCount - 1, + "retrieved the expected number of processes via onAvailable. All but the first one where we have thrown." + ); + + targetCommand.destroy(); + + await commands.destroy(); +} diff --git a/devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js b/devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js new file mode 100644 index 0000000000..6dd99d243b --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_watcher_actor_getter_caching.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that watcher front/actor APIs do not lead to create duplicate actors. + +const TEST_URL = "data:text/html;charset=utf-8,Actor caching test"; + +add_task(async function () { + info("Setup the test page with workers of all types"); + const tab = await addTab(TEST_URL); + + info("Create a target list for a tab target"); + const commands = await CommandsFactory.forTab(tab); + const targetCommand = commands.targetCommand; + await targetCommand.startListening(); + + const { watcherFront } = targetCommand; + ok(watcherFront, "A watcherFront is available on targetCommand"); + + info("Check that getNetworkParentActor does not create duplicate actors"); + testActorGetter( + watcherFront, + () => watcherFront.getNetworkParentActor(), + "networkParent" + ); + + info("Check that getBreakpointListActor does not create duplicate actors"); + testActorGetter( + watcherFront, + () => watcherFront.getBreakpointListActor(), + "breakpoint-list" + ); + + info( + "Check that getTargetConfigurationActor does not create duplicate actors" + ); + testActorGetter( + watcherFront, + () => watcherFront.getTargetConfigurationActor(), + "target-configuration" + ); + + info( + "Check that getThreadConfigurationActor does not create duplicate actors" + ); + testActorGetter( + watcherFront, + () => watcherFront.getThreadConfigurationActor(), + "thread-configuration" + ); + + targetCommand.destroy(); + await commands.waitForRequestsToSettle(); + await commands.destroy(); +}); + +/** + * Check that calling an actor getter method on the watcher front leads to the + * creation of at most 1 actor. + */ +async function testActorGetter(watcherFront, actorGetterFn, typeName) { + checkPoolChildrenSize(watcherFront, typeName, 0); + + const actor = await actorGetterFn(); + checkPoolChildrenSize(watcherFront, typeName, 1); + + const otherActor = await actorGetterFn(); + is(actor, otherActor, "Returned the same actor for " + typeName); + + checkPoolChildrenSize(watcherFront, typeName, 1); +} + +/** + * Assert that a given parent pool has the expected number of children for + * a given typeName. + */ +function checkPoolChildrenSize(parentPool, typeName, expected) { + const children = [...parentPool.poolChildren()]; + const childrenByType = children.filter(pool => pool.typeName === typeName); + is( + childrenByType.length, + expected, + `${parentPool.actorID} should have ${expected} children of type ${typeName}` + ); +} diff --git a/devtools/shared/commands/target/tests/fission_document.html b/devtools/shared/commands/target/tests/fission_document.html new file mode 100644 index 0000000000..62afe347e3 --- /dev/null +++ b/devtools/shared/commands/target/tests/fission_document.html @@ -0,0 +1,47 @@ + + + + + Test fission document + + + + +

Test fission iframe

+ + + + diff --git a/devtools/shared/commands/target/tests/fission_iframe.html b/devtools/shared/commands/target/tests/fission_iframe.html new file mode 100644 index 0000000000..deae49f833 --- /dev/null +++ b/devtools/shared/commands/target/tests/fission_iframe.html @@ -0,0 +1,29 @@ + + + + + Test fission iframe document + + + + +

remote iframe

+ + diff --git a/devtools/shared/commands/target/tests/head.js b/devtools/shared/commands/target/tests/head.js new file mode 100644 index 0000000000..ecb3fc1828 --- /dev/null +++ b/devtools/shared/commands/target/tests/head.js @@ -0,0 +1,32 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"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 createLocalClient() { + // Instantiate a minimal server + DevToolsServer.init(); + DevToolsServer.allowChromeProcess = true; + if (!DevToolsServer.createRootActor) { + DevToolsServer.registerAllActors(); + } + const transport = DevToolsServer.connectPipe(); + const client = new DevToolsClient(transport); + await client.connect(); + return client; +} diff --git a/devtools/shared/commands/target/tests/incremental-js-value-script.sjs b/devtools/shared/commands/target/tests/incremental-js-value-script.sjs new file mode 100644 index 0000000000..a612a3cb59 --- /dev/null +++ b/devtools/shared/commands/target/tests/incremental-js-value-script.sjs @@ -0,0 +1,23 @@ +"use strict"; + +function handleRequest(request, response) { + const Etag = '"4d881ab-b03-435f0a0f9ef00"'; + const IfNoneMatch = request.hasHeader("If-None-Match") + ? request.getHeader("If-None-Match") + : ""; + + const counter = getState("cache-counter") || 1; + const page = "" + counter; + + setState("cache-counter", "" + (parseInt(counter, 10) + 1)); + + response.setHeader("Etag", Etag, false); + + if (IfNoneMatch === Etag) { + response.setStatusLine(request.httpVersion, "304", "Not Modified"); + } else { + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Content-Length", page.length + "", false); + response.write(page); + } +} diff --git a/devtools/shared/commands/target/tests/simple_document.html b/devtools/shared/commands/target/tests/simple_document.html new file mode 100644 index 0000000000..d6a449e489 --- /dev/null +++ b/devtools/shared/commands/target/tests/simple_document.html @@ -0,0 +1,12 @@ + + + + + Test empty document + + + +

Test empty document

+ + diff --git a/devtools/shared/commands/target/tests/test_service_worker.js b/devtools/shared/commands/target/tests/test_service_worker.js new file mode 100644 index 0000000000..aabc3fda0f --- /dev/null +++ b/devtools/shared/commands/target/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/target/tests/test_sw_page.html b/devtools/shared/commands/target/tests/test_sw_page.html new file mode 100644 index 0000000000..38aad04259 --- /dev/null +++ b/devtools/shared/commands/target/tests/test_sw_page.html @@ -0,0 +1,19 @@ + + + + + Test sw page + + + +

Test sw page

+ + + + diff --git a/devtools/shared/commands/target/tests/test_sw_page_worker.js b/devtools/shared/commands/target/tests/test_sw_page_worker.js new file mode 100644 index 0000000000..29cda68560 --- /dev/null +++ b/devtools/shared/commands/target/tests/test_sw_page_worker.js @@ -0,0 +1,5 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// We don't need any computation in the worker, +// just it to be alive diff --git a/devtools/shared/commands/target/tests/test_worker.js b/devtools/shared/commands/target/tests/test_worker.js new file mode 100644 index 0000000000..ce3dd39cea --- /dev/null +++ b/devtools/shared/commands/target/tests/test_worker.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +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/thread-configuration/moz.build b/devtools/shared/commands/thread-configuration/moz.build new file mode 100644 index 0000000000..28e8e0ffc4 --- /dev/null +++ b/devtools/shared/commands/thread-configuration/moz.build @@ -0,0 +1,7 @@ +# 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/. + +DevToolsModules( + "thread-configuration-command.js", +) diff --git a/devtools/shared/commands/thread-configuration/tests/browser.toml b/devtools/shared/commands/thread-configuration/tests/browser.toml new file mode 100644 index 0000000000..bbd5485874 --- /dev/null +++ b/devtools/shared/commands/thread-configuration/tests/browser.toml @@ -0,0 +1,7 @@ +[DEFAULT] +tags = "devtools" +subsuite = "devtools" +support-files = [ + "!/devtools/client/shared/test/shared-head.js", + "head.js", +] diff --git a/devtools/shared/commands/thread-configuration/tests/head.js b/devtools/shared/commands/thread-configuration/tests/head.js new file mode 100644 index 0000000000..ce65b3d827 --- /dev/null +++ b/devtools/shared/commands/thread-configuration/tests/head.js @@ -0,0 +1,12 @@ +/* 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 +); diff --git a/devtools/shared/commands/thread-configuration/thread-configuration-command.js b/devtools/shared/commands/thread-configuration/thread-configuration-command.js new file mode 100644 index 0000000000..0db1c2a285 --- /dev/null +++ b/devtools/shared/commands/thread-configuration/thread-configuration-command.js @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * The ThreadConfigurationCommand should be used to maintain thread settings + * sent from the client for the thread actor. + * + * See the ThreadConfigurationActor for a list of supported configuration options. + */ +class ThreadConfigurationCommand { + constructor({ commands, watcherFront }) { + this._commands = commands; + this._watcherFront = watcherFront; + } + + /** + * Return a promise that resolves to the related thread configuration actor's front. + * + * @return {Promise} + */ + async getThreadConfigurationFront() { + const front = await this._watcherFront.getThreadConfigurationActor(); + return front; + } + + async updateConfiguration(configuration) { + if (this._commands.targetCommand.hasTargetWatcherSupport()) { + // Remove thread options that are not currently supported by + // the thread configuration actor. + const filteredConfiguration = Object.fromEntries( + Object.entries(configuration).filter( + ([key, value]) => !["breakpoints", "eventBreakpoints"].includes(key) + ) + ); + + const threadConfigurationFront = await this.getThreadConfigurationFront(); + const updatedConfiguration = + await threadConfigurationFront.updateConfiguration( + filteredConfiguration + ); + this._configuration = updatedConfiguration; + } + + let threadFronts = await this._commands.targetCommand.getAllFronts( + this._commands.targetCommand.ALL_TYPES, + "thread" + ); + + // Lets always call reconfigure for all the target types that do not + // have target watcher support yet. e.g In the browser, even + // though `hasTargetWatcherSupport()` is true, only + // FRAME and CONTENT PROCESS targets use watcher actors, + // WORKER targets are supported via the legacy listerners. + threadFronts = threadFronts.filter( + threadFront => + !this._commands.targetCommand.hasTargetWatcherSupport( + threadFront.targetFront.targetType + ) + ); + + // Ignore threads that fail to be configured. + // Some workers may be destroying and `reconfigure` would be rejected. + await Promise.allSettled( + threadFronts.map(threadFront => threadFront.reconfigure(configuration)) + ); + } +} + +module.exports = ThreadConfigurationCommand; diff --git a/devtools/shared/commands/tracer/moz.build b/devtools/shared/commands/tracer/moz.build new file mode 100644 index 0000000000..63b3033655 --- /dev/null +++ b/devtools/shared/commands/tracer/moz.build @@ -0,0 +1,7 @@ +# 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/. + +DevToolsModules( + "tracer-command.js", +) diff --git a/devtools/shared/commands/tracer/tracer-command.js b/devtools/shared/commands/tracer/tracer-command.js new file mode 100644 index 0000000000..f512c15d9e --- /dev/null +++ b/devtools/shared/commands/tracer/tracer-command.js @@ -0,0 +1,85 @@ +/* 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"; + +class TracerCommand { + constructor({ commands }) { + this.#targetCommand = commands.targetCommand; + this.#targetConfigurationCommand = commands.targetConfigurationCommand; + this.#resourceCommand = commands.resourceCommand; + } + + #resourceCommand; + #targetCommand; + #targetConfigurationCommand; + #isTracing = false; + + async initialize() { + return this.#resourceCommand.watchResources( + [this.#resourceCommand.TYPES.JSTRACER_STATE], + { onAvailable: this.onResourcesAvailable } + ); + } + destroy() { + this.#resourceCommand.unwatchResources( + [this.#resourceCommand.TYPES.JSTRACER_STATE], + { onAvailable: this.onResourcesAvailable } + ); + } + + onResourcesAvailable = resources => { + for (const resource of resources) { + if (resource.resourceType != this.#resourceCommand.TYPES.JSTRACER_STATE) { + continue; + } + this.#isTracing = resource.enabled; + } + }; + + /** + * Get the dictionary passed to the server codebase as a SessionData. + * This contains all settings to fine tune the tracer actual behavior. + * + * @return {JSON} + * Configuration object. + */ + #getTracingOptions() { + return { + logMethod: Services.prefs.getStringPref( + "devtools.debugger.javascript-tracing-log-method", + "" + ), + traceValues: Services.prefs.getBoolPref( + "devtools.debugger.javascript-tracing-values", + false + ), + traceOnNextInteraction: Services.prefs.getBoolPref( + "devtools.debugger.javascript-tracing-on-next-interaction", + false + ), + traceOnNextLoad: Services.prefs.getBoolPref( + "devtools.debugger.javascript-tracing-on-next-load", + false + ), + traceFunctionReturn: Services.prefs.getBoolPref( + "devtools.debugger.javascript-tracing-function-return", + false + ), + }; + } + + /** + * Toggle JavaScript tracing for all targets. + */ + async toggle() { + this.#isTracing = !this.#isTracing; + + await this.#targetConfigurationCommand.updateConfiguration({ + tracerOptions: this.#isTracing ? this.#getTracingOptions() : undefined, + }); + } +} + +module.exports = TracerCommand; -- cgit v1.2.3