From 0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 03:47:29 +0200 Subject: Adding upstream version 115.8.0esr. Signed-off-by: Daniel Baumann --- devtools/shared/.eslintrc.js | 13 + devtools/shared/DevToolsInfaillibleUtils.sys.mjs | 101 + devtools/shared/DevToolsUtils.js | 1023 ++ devtools/shared/ThreadSafeDevToolsUtils.js | 363 + devtools/shared/accessibility.js | 192 + devtools/shared/async-storage.js | 226 + devtools/shared/async-utils.js | 71 + devtools/shared/commands/README.md | 44 + devtools/shared/commands/commands-factory.js | 245 + devtools/shared/commands/index.js | 133 + .../inspected-window/inspected-window-command.js | 145 + .../shared/commands/inspected-window/moz.build | 10 + .../commands/inspected-window/tests/browser.ini | 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 | 89 + .../shared/commands/inspector/inspector-command.js | 483 + devtools/shared/commands/inspector/moz.build | 10 + .../shared/commands/inspector/tests/browser.ini | 15 + ...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 | 20 + devtools/shared/commands/network/moz.build | 10 + .../shared/commands/network/network-command.js | 96 + devtools/shared/commands/network/tests/browser.ini | 11 + .../browser_network_command_request_blocking.js | 61 + .../browser_network_command_sendHTTPRequest.js | 78 + devtools/shared/commands/network/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 | 1352 ++ .../resource/tests/breakpoint_document.html | 21 + .../shared/commands/resource/tests/browser.ini | 82 + .../browser_browser_resources_console_messages.js | 87 + .../tests/browser_resources_clear_resources.js | 90 + .../tests/browser_resources_client_caching.js | 376 + .../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 | 210 + .../tests/browser_resources_document_events.js | 711 ++ .../tests/browser_resources_error_messages.js | 877 ++ .../tests/browser_resources_getAllResources.js | 124 + .../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 | 316 + .../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 | 111 + .../resource/tests/browser_resources_root_node.js | 125 + .../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 | 450 + .../tests/browser_resources_stylesheets.js | 557 + .../tests/browser_resources_stylesheets_import.js | 66 + .../browser_resources_stylesheets_navigation.js | 257 + ...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 | 91 + .../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 | 240 + .../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 | 137 + .../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 | 149 + devtools/shared/commands/script/tests/browser.ini | 11 + .../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.ini | 17 + .../tests/browser_target_configuration_command.js | 79 + ...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 | 101 + 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 | 316 + .../legacy-sharedworkers-watcher.js | 19 + .../legacy-workers-watcher.js | 238 + .../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 | 1173 ++ devtools/shared/commands/target/tests/browser.ini | 48 + .../target/tests/browser_target_command_bfcache.js | 499 + .../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 | 389 + .../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 | 283 + .../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.ini | 7 + .../commands/thread-configuration/tests/head.js | 12 + .../thread-configuration-command.js | 72 + devtools/shared/compatibility/README.md | 27 + devtools/shared/compatibility/bin/update.js | 197 + .../compatibility/compatibility-user-settings.js | 127 + devtools/shared/compatibility/constants.js | 27 + .../compatibility/dataset/css-properties.json | 1 + devtools/shared/compatibility/dataset/moz.build | 9 + devtools/shared/compatibility/helpers.js | 112 + devtools/shared/compatibility/moz.build | 18 + devtools/shared/compatibility/package.json | 13 + devtools/shared/constants.js | 166 + devtools/shared/content-observer.js | 73 + devtools/shared/css/color-db.js | 323 + devtools/shared/css/color.js | 841 ++ devtools/shared/css/constants.js | 38 + .../shared/css/generated/generate-properties-db.js | 59 + devtools/shared/css/generated/mach_commands.py | 120 + devtools/shared/css/generated/moz.build | 9 + devtools/shared/css/generated/properties-db.js | 12326 +++++++++++++++++++ devtools/shared/css/generated/properties-db.js.in | 21 + devtools/shared/css/lexer.js | 1522 +++ devtools/shared/css/moz.build | 18 + devtools/shared/css/parsing-utils.js | 736 ++ devtools/shared/css/properties-db.js | 53 + devtools/shared/debounce.js | 45 + devtools/shared/defer.js | 25 + devtools/shared/discovery/discovery.js | 427 + devtools/shared/discovery/moz.build | 11 + .../shared/discovery/tests/xpcshell/.eslintrc.js | 6 + .../discovery/tests/xpcshell/test_discovery.js | 158 + .../shared/discovery/tests/xpcshell/xpcshell.ini | 6 + devtools/shared/dom-helpers.js | 51 + devtools/shared/dom-node-constants.js | 27 + devtools/shared/dom-node-filter-constants.js | 25 + devtools/shared/event-emitter.js | 470 + devtools/shared/extend.js | 15 + devtools/shared/flags.js | 69 + devtools/shared/generate-uuid.js | 14 + devtools/shared/heapsnapshot/AutoMemMap.cpp | 67 + devtools/shared/heapsnapshot/AutoMemMap.h | 77 + devtools/shared/heapsnapshot/CensusUtils.js | 502 + devtools/shared/heapsnapshot/CoreDump.pb.cc | 2242 ++++ devtools/shared/heapsnapshot/CoreDump.pb.h | 2883 +++++ devtools/shared/heapsnapshot/CoreDump.proto | 152 + devtools/shared/heapsnapshot/DeserializedNode.cpp | 124 + devtools/shared/heapsnapshot/DeserializedNode.h | 308 + devtools/shared/heapsnapshot/DominatorTree.cpp | 133 + devtools/shared/heapsnapshot/DominatorTree.h | 66 + devtools/shared/heapsnapshot/DominatorTreeNode.js | 378 + .../heapsnapshot/FileDescriptorOutputStream.cpp | 81 + .../heapsnapshot/FileDescriptorOutputStream.h | 38 + devtools/shared/heapsnapshot/HeapAnalysesClient.js | 285 + devtools/shared/heapsnapshot/HeapAnalysesWorker.js | 338 + devtools/shared/heapsnapshot/HeapSnapshot.cpp | 1579 +++ devtools/shared/heapsnapshot/HeapSnapshot.h | 216 + .../shared/heapsnapshot/HeapSnapshotFileUtils.js | 85 + .../heapsnapshot/HeapSnapshotTempFileHelperChild.h | 31 + .../HeapSnapshotTempFileHelperParent.cpp | 56 + .../HeapSnapshotTempFileHelperParent.h | 36 + .../heapsnapshot/PHeapSnapshotTempFileHelper.ipdl | 37 + .../heapsnapshot/ZeroCopyNSIOutputStream.cpp | 79 + .../shared/heapsnapshot/ZeroCopyNSIOutputStream.h | 69 + devtools/shared/heapsnapshot/census-tree-node.js | 764 ++ .../heapsnapshot/generate-core-dump-sources.sh | 26 + devtools/shared/heapsnapshot/moz.build | 59 + devtools/shared/heapsnapshot/shortest-paths.js | 93 + .../shared/heapsnapshot/tests/browser/browser.ini | 8 + .../browser/browser_saveHeapSnapshot_e10s_01.js | 30 + .../shared/heapsnapshot/tests/chrome/chrome.ini | 8 + .../tests/chrome/test_DominatorTree_01.html | 40 + .../tests/chrome/test_SaveHeapSnapshot.html | 27 + .../tests/gtest/DeserializedNodeUbiNodes.cpp | 95 + .../gtest/DeserializedStackFrameUbiStackFrames.cpp | 96 + .../shared/heapsnapshot/tests/gtest/DevTools.cpp | 7 + .../shared/heapsnapshot/tests/gtest/DevTools.h | 214 + .../tests/gtest/DoesCrossCompartmentBoundaries.cpp | 67 + .../gtest/DoesntCrossCompartmentBoundaries.cpp | 58 + .../tests/gtest/SerializesEdgeNames.cpp | 49 + .../gtest/SerializesEverythingInHeapGraphOnce.cpp | 34 + .../tests/gtest/SerializesTypeNames.cpp | 27 + devtools/shared/heapsnapshot/tests/gtest/moz.build | 32 + .../heapsnapshot/tests/xpcshell/.eslintrc.js | 6 + .../heapsnapshot/tests/xpcshell/Census.sys.mjs | 176 + .../heapsnapshot/tests/xpcshell/Match.sys.mjs | 218 + .../tests/xpcshell/dominator-tree-worker.js | 54 + .../tests/xpcshell/head_heapsnapshot.js | 550 + .../tests/xpcshell/heap-snapshot-worker.js | 52 + ...est_DominatorTreeNode_LabelAndShallowSize_01.js | 49 + ...est_DominatorTreeNode_LabelAndShallowSize_02.js | 48 + ...est_DominatorTreeNode_LabelAndShallowSize_03.js | 50 + ...est_DominatorTreeNode_LabelAndShallowSize_04.js | 55 + ...est_DominatorTreeNode_attachShortestPaths_01.js | 141 + ...st_DominatorTreeNode_getNodeByIdAlongPath_01.js | 49 + .../xpcshell/test_DominatorTreeNode_insert_01.js | 119 + .../xpcshell/test_DominatorTreeNode_insert_02.js | 37 + .../xpcshell/test_DominatorTreeNode_insert_03.js | 124 + .../test_DominatorTreeNode_partialTraversal_01.js | 150 + .../tests/xpcshell/test_DominatorTree_01.js | 24 + .../tests/xpcshell/test_DominatorTree_02.js | 41 + .../tests/xpcshell/test_DominatorTree_03.js | 21 + .../tests/xpcshell/test_DominatorTree_04.js | 26 + .../tests/xpcshell/test_DominatorTree_05.js | 97 + .../tests/xpcshell/test_DominatorTree_06.js | 62 + .../test_HeapAnalyses_computeDominatorTree_01.js | 22 + .../test_HeapAnalyses_computeDominatorTree_02.js | 20 + .../test_HeapAnalyses_deleteHeapSnapshot_01.js | 56 + .../test_HeapAnalyses_deleteHeapSnapshot_02.js | 19 + .../test_HeapAnalyses_deleteHeapSnapshot_03.js | 60 + .../test_HeapAnalyses_getCensusIndividuals_01.js | 110 + .../test_HeapAnalyses_getCreationTime_01.js | 57 + .../test_HeapAnalyses_getDominatorTree_01.js | 87 + .../test_HeapAnalyses_getDominatorTree_02.js | 28 + ...test_HeapAnalyses_getImmediatelyDominated_01.js | 91 + .../test_HeapAnalyses_readHeapSnapshot_01.js | 15 + .../test_HeapAnalyses_takeCensusDiff_01.js | 62 + .../test_HeapAnalyses_takeCensusDiff_02.js | 64 + .../xpcshell/test_HeapAnalyses_takeCensus_01.js | 24 + .../xpcshell/test_HeapAnalyses_takeCensus_02.js | 26 + .../xpcshell/test_HeapAnalyses_takeCensus_03.js | 53 + .../xpcshell/test_HeapAnalyses_takeCensus_04.js | 133 + .../xpcshell/test_HeapAnalyses_takeCensus_05.js | 50 + .../xpcshell/test_HeapAnalyses_takeCensus_06.js | 114 + .../xpcshell/test_HeapAnalyses_takeCensus_07.js | 57 + .../test_HeapSnapshot_computeShortestPaths_01.js | 87 + .../test_HeapSnapshot_computeShortestPaths_02.js | 50 + .../xpcshell/test_HeapSnapshot_creationTime_01.js | 33 + .../xpcshell/test_HeapSnapshot_deepStack_01.js | 87 + .../xpcshell/test_HeapSnapshot_describeNode_01.js | 43 + .../test_HeapSnapshot_getObjectNodeId_01.js | 57 + .../xpcshell/test_HeapSnapshot_takeCensus_01.js | 33 + .../xpcshell/test_HeapSnapshot_takeCensus_02.js | 61 + .../xpcshell/test_HeapSnapshot_takeCensus_03.js | 35 + .../xpcshell/test_HeapSnapshot_takeCensus_04.js | 43 + .../xpcshell/test_HeapSnapshot_takeCensus_05.js | 31 + .../xpcshell/test_HeapSnapshot_takeCensus_06.js | 127 + .../xpcshell/test_HeapSnapshot_takeCensus_07.js | 124 + .../xpcshell/test_HeapSnapshot_takeCensus_08.js | 85 + .../xpcshell/test_HeapSnapshot_takeCensus_09.js | 111 + .../xpcshell/test_HeapSnapshot_takeCensus_10.js | 74 + .../xpcshell/test_HeapSnapshot_takeCensus_11.js | 131 + .../xpcshell/test_HeapSnapshot_takeCensus_12.js | 60 + .../tests/xpcshell/test_ReadHeapSnapshot.js | 22 + .../test_ReadHeapSnapshot_with_allocations.js | 51 + .../test_ReadHeapSnapshot_with_utf8_paths.js | 27 + .../tests/xpcshell/test_ReadHeapSnapshot_worker.js | 41 + .../tests/xpcshell/test_SaveHeapSnapshot.js | 121 + .../tests/xpcshell/test_census-tree-node-01.js | 77 + .../tests/xpcshell/test_census-tree-node-02.js | 139 + .../tests/xpcshell/test_census-tree-node-03.js | 97 + .../tests/xpcshell/test_census-tree-node-04.js | 160 + .../tests/xpcshell/test_census-tree-node-05.js | 150 + .../tests/xpcshell/test_census-tree-node-06.js | 199 + .../tests/xpcshell/test_census-tree-node-07.js | 206 + .../tests/xpcshell/test_census-tree-node-08.js | 143 + .../tests/xpcshell/test_census-tree-node-09.js | 44 + .../tests/xpcshell/test_census-tree-node-10.js | 51 + .../tests/xpcshell/test_census_diff_01.js | 75 + .../tests/xpcshell/test_census_diff_02.js | 26 + .../tests/xpcshell/test_census_diff_03.js | 87 + .../tests/xpcshell/test_census_diff_04.js | 64 + .../tests/xpcshell/test_census_diff_05.js | 35 + .../tests/xpcshell/test_census_diff_06.js | 139 + .../tests/xpcshell/test_census_filtering_01.js | 111 + .../tests/xpcshell/test_census_filtering_02.js | 125 + .../tests/xpcshell/test_census_filtering_03.js | 67 + .../tests/xpcshell/test_census_filtering_04.js | 105 + .../tests/xpcshell/test_census_filtering_05.js | 77 + .../xpcshell/test_countToBucketBreakdown_01.js | 37 + .../tests/xpcshell/test_deduplicatePaths_01.js | 87 + .../tests/xpcshell/test_getCensusIndividuals_01.js | 60 + .../tests/xpcshell/test_getReportLeaves_01.js | 135 + .../xpcshell/test_saveHeapSnapshot_e10s_01.js | 9 + .../heapsnapshot/tests/xpcshell/xpcshell.ini | 100 + .../shared/images/command-pick-remote-touch.svg | 7 + devtools/shared/images/command-pick.svg | 7 + devtools/shared/images/resume.svg | 6 + devtools/shared/images/stepOver.svg | 9 + devtools/shared/indentation.js | 170 + devtools/shared/indexed-db.js | 49 + devtools/shared/inspector/css-logic.js | 803 ++ devtools/shared/inspector/moz.build | 7 + devtools/shared/inspector/utils.js | 22 + devtools/shared/jar.mn | 24 + devtools/shared/jsbeautify/UPGRADING.md | 36 + devtools/shared/jsbeautify/beautify.js | 9 + devtools/shared/jsbeautify/moz.build | 13 + devtools/shared/jsbeautify/src/beautify-css.js | 1683 +++ devtools/shared/jsbeautify/src/beautify-html.js | 3166 +++++ devtools/shared/jsbeautify/src/beautify-js.js | 4045 ++++++ devtools/shared/jsbeautify/src/moz.build | 11 + devtools/shared/l10n.js | 273 + devtools/shared/layout/dom-matrix-2d.js | 297 + devtools/shared/layout/moz.build | 7 + devtools/shared/layout/utils.js | 930 ++ .../loader/DistinctSystemPrincipalLoader.sys.mjs | 45 + devtools/shared/loader/Loader.sys.mjs | 209 + devtools/shared/loader/base-loader.sys.mjs | 640 + devtools/shared/loader/browser-loader-mocks.js | 72 + devtools/shared/loader/browser-loader.js | 239 + devtools/shared/loader/builtin-modules.js | 203 + devtools/shared/loader/loader-plugin-raw.sys.mjs | 39 + devtools/shared/loader/moz.build | 21 + devtools/shared/loader/worker-loader.js | 536 + .../shared/locales/en-US/accessibility.properties | 142 + .../locales/en-US/debugger-paused-reasons.ftl | 85 + devtools/shared/locales/en-US/debugger.properties | 59 + .../shared/locales/en-US/eyedropper.properties | 14 + devtools/shared/locales/en-US/highlighters.ftl | 66 + .../shared/locales/en-US/screenshot.properties | 138 + devtools/shared/locales/en-US/shared.properties | 6 + .../shared/locales/en-US/styleinspector.properties | 249 + devtools/shared/locales/jar.mn | 11 + devtools/shared/locales/l10n.toml | 12 + devtools/shared/locales/moz.build | 7 + devtools/shared/moz.build | 79 + devtools/shared/natural-sort.js | 148 + .../shared/network-observer/ChannelMap.sys.mjs | 129 + .../shared/network-observer/NetworkHelper.sys.mjs | 917 ++ .../network-observer/NetworkObserver.sys.mjs | 1397 +++ .../network-observer/NetworkOverride.sys.mjs | 70 + .../NetworkResponseListener.sys.mjs | 609 + .../NetworkThrottleManager.sys.mjs | 495 + .../shared/network-observer/NetworkUtils.sys.mjs | 670 + devtools/shared/network-observer/README.md | 9 + .../network-observer/WildcardToRegexp.sys.mjs | 28 + devtools/shared/network-observer/moz.build | 20 + .../network-observer/test/browser/browser.ini | 15 + .../test/browser/browser_networkobserver.js | 72 + .../browser_networkobserver_invalid_constructor.js | 49 + .../browser/browser_networkobserver_override.js | 179 + .../test/browser/doc_network-observer.html | 25 + .../network-observer/test/browser/gzipped.sjs | 44 + .../shared/network-observer/test/browser/head.js | 92 + .../network-observer/test/browser/override.html | 1 + .../network-observer/test/browser/override.js | 2 + .../browser/sjs_network-observer-test-server.sjs | 198 + .../shared/network-observer/test/xpcshell/head.js | 9 + .../test/xpcshell/test_network_helper.js | 90 + .../xpcshell/test_security-info-certificate.js | 84 + .../test/xpcshell/test_security-info-parser.js | 54 + .../test_security-info-protocol-version.js | 48 + .../test/xpcshell/test_security-info-state.js | 122 + .../xpcshell/test_security-info-static-hpkp.js | 41 + .../test_security-info-weakness-reasons.js | 39 + .../test/xpcshell/test_throttle.js | 162 + .../network-observer/test/xpcshell/xpcshell.ini | 15 + devtools/shared/node-properties/UPGRADING.md | 12 + devtools/shared/node-properties/moz.build | 9 + devtools/shared/node-properties/node-properties.js | 776 ++ devtools/shared/path.js | 27 + devtools/shared/performance-new/moz.build | 12 + devtools/shared/performance-new/recording-utils.js | 39 + devtools/shared/picker-constants.js | 14 + devtools/shared/platform/CacheEntry.sys.mjs | 115 + devtools/shared/platform/clipboard.js | 59 + devtools/shared/platform/moz.build | 11 + devtools/shared/platform/stack.js | 64 + devtools/shared/plural-form.js | 203 + devtools/shared/protocol.js | 36 + devtools/shared/protocol/Actor.js | 260 + .../shared/protocol/Actor/generateActorSpec.js | 62 + devtools/shared/protocol/Actor/moz.build | 8 + devtools/shared/protocol/Front.js | 411 + .../shared/protocol/Front/FrontClassWithSpec.js | 118 + devtools/shared/protocol/Front/moz.build | 8 + devtools/shared/protocol/Pool.js | 220 + devtools/shared/protocol/Request.js | 169 + devtools/shared/protocol/Response.js | 119 + devtools/shared/protocol/lazy-pool.js | 224 + devtools/shared/protocol/moz.build | 22 + .../shared/protocol/tests/xpcshell/.eslintrc.js | 6 + devtools/shared/protocol/tests/xpcshell/head.js | 99 + .../protocol/tests/xpcshell/test_protocol_abort.js | 79 + .../protocol/tests/xpcshell/test_protocol_async.js | 192 + .../tests/xpcshell/test_protocol_children.js | 700 ++ .../protocol/tests/xpcshell/test_protocol_index.js | 52 + .../xpcshell/test_protocol_invalid_response.js | 52 + .../tests/xpcshell/test_protocol_lifecycle.js | 27 + .../tests/xpcshell/test_protocol_longstring.js | 310 + .../tests/xpcshell/test_protocol_simple.js | 316 + .../protocol/tests/xpcshell/test_protocol_stack.js | 98 + .../protocol/tests/xpcshell/test_protocol_types.js | 65 + .../tests/xpcshell/test_protocol_unregister.js | 41 + .../tests/xpcshell/test_protocol_watchFronts.js | 183 + .../shared/protocol/tests/xpcshell/xpcshell.ini | 19 + devtools/shared/protocol/types.js | 587 + devtools/shared/protocol/utils.js | 44 + devtools/shared/qrcode/decoder/LICENSE | 201 + devtools/shared/qrcode/decoder/index.js | 2374 ++++ devtools/shared/qrcode/decoder/moz.build | 9 + devtools/shared/qrcode/encoder/LICENSE | 19 + devtools/shared/qrcode/encoder/index.js | 1674 +++ devtools/shared/qrcode/encoder/moz.build | 9 + devtools/shared/qrcode/index.js | 116 + devtools/shared/qrcode/moz.build | 18 + devtools/shared/qrcode/tests/chrome/chrome.ini | 5 + .../shared/qrcode/tests/chrome/test_decode.html | 66 + devtools/shared/qrcode/tests/xpcshell/.eslintrc.js | 6 + .../shared/qrcode/tests/xpcshell/test_encode.js | 30 + devtools/shared/qrcode/tests/xpcshell/xpcshell.ini | 6 + .../shared/security/DevToolsSocketStatus.sys.mjs | 60 + devtools/shared/security/auth.js | 220 + devtools/shared/security/moz.build | 15 + devtools/shared/security/prompt.js | 197 + devtools/shared/security/socket.js | 685 ++ devtools/shared/security/tests/chrome/chrome.ini | 4 + .../tests/chrome/test_websocket-transport.html | 72 + .../shared/security/tests/xpcshell/.eslintrc.js | 6 + .../shared/security/tests/xpcshell/head_dbg.js | 95 + .../tests/xpcshell/test_devtools_socket_status.js | 137 + .../shared/security/tests/xpcshell/testactors.js | 16 + .../shared/security/tests/xpcshell/xpcshell.ini | 10 + devtools/shared/specs/accessibility.js | 299 + devtools/shared/specs/addon/addons.js | 33 + devtools/shared/specs/addon/moz.build | 10 + .../specs/addon/webextension-inspected-window.js | 119 + devtools/shared/specs/animation.js | 110 + devtools/shared/specs/array-buffer.js | 24 + devtools/shared/specs/blackboxing.js | 42 + devtools/shared/specs/breakpoint-list.js | 54 + devtools/shared/specs/changes.js | 40 + devtools/shared/specs/compatibility.js | 69 + devtools/shared/specs/css-properties.js | 23 + devtools/shared/specs/descriptors/moz.build | 12 + devtools/shared/specs/descriptors/process.js | 41 + devtools/shared/specs/descriptors/tab.js | 50 + devtools/shared/specs/descriptors/webextension.js | 58 + devtools/shared/specs/descriptors/worker.js | 32 + devtools/shared/specs/device.js | 18 + devtools/shared/specs/environment.js | 14 + devtools/shared/specs/frame.js | 21 + devtools/shared/specs/heap-snapshot-file.js | 23 + devtools/shared/specs/highlighters.js | 44 + devtools/shared/specs/index.js | 404 + devtools/shared/specs/inspector.js | 79 + devtools/shared/specs/layout.js | 75 + devtools/shared/specs/manifest.js | 21 + devtools/shared/specs/memory.js | 124 + devtools/shared/specs/moz.build | 63 + devtools/shared/specs/network-content.js | 32 + devtools/shared/specs/network-event.js | 220 + devtools/shared/specs/network-parent.js | 82 + devtools/shared/specs/node.js | 160 + devtools/shared/specs/object.js | 214 + devtools/shared/specs/page-style.js | 126 + devtools/shared/specs/perf.js | 90 + devtools/shared/specs/preference.js | 55 + .../shared/specs/private-properties-iterator.js | 42 + devtools/shared/specs/property-iterator.js | 45 + devtools/shared/specs/reflow.js | 36 + devtools/shared/specs/responsive.js | 40 + devtools/shared/specs/root.js | 139 + devtools/shared/specs/screenshot-content.js | 34 + devtools/shared/specs/screenshot.js | 37 + devtools/shared/specs/source.js | 87 + devtools/shared/specs/storage.js | 308 + devtools/shared/specs/string.js | 85 + devtools/shared/specs/style-rule.js | 73 + devtools/shared/specs/style-sheets.js | 49 + devtools/shared/specs/style/moz.build | 9 + devtools/shared/specs/style/style-types.js | 78 + devtools/shared/specs/symbol-iterator.js | 42 + devtools/shared/specs/symbol.js | 20 + devtools/shared/specs/target-configuration.js | 50 + devtools/shared/specs/targets/content-process.js | 55 + devtools/shared/specs/targets/moz.build | 13 + devtools/shared/specs/targets/parent-process.js | 24 + devtools/shared/specs/targets/webextension.js | 18 + devtools/shared/specs/targets/window-global.js | 156 + devtools/shared/specs/targets/worker.js | 30 + devtools/shared/specs/thread-configuration.js | 31 + devtools/shared/specs/thread.js | 189 + devtools/shared/specs/tracer.js | 27 + devtools/shared/specs/walker.js | 389 + devtools/shared/specs/watcher.js | 123 + devtools/shared/specs/webconsole.js | 201 + devtools/shared/specs/worker/moz.build | 11 + devtools/shared/specs/worker/push-subscription.js | 12 + .../specs/worker/service-worker-registration.js | 48 + devtools/shared/specs/worker/service-worker.js | 12 + devtools/shared/sprintfjs/UPGRADING.md | 17 + devtools/shared/sprintfjs/moz.build | 9 + devtools/shared/sprintfjs/sprintf.js | 283 + devtools/shared/storage/moz.build | 9 + devtools/shared/storage/utils.js | 161 + devtools/shared/storage/vendor/JSON5_LICENSE | 23 + devtools/shared/storage/vendor/JSON5_UPGRADING.md | 36 + devtools/shared/storage/vendor/json5.js | 1713 +++ devtools/shared/storage/vendor/moz.build | 13 + .../storage/vendor/stringvalidator/UPDATING.md | 142 + .../storage/vendor/stringvalidator/moz.build | 15 + .../tests/xpcshell/head_stringvalidator.js | 15 + .../tests/xpcshell/test_sanitizers.js | 419 + .../tests/xpcshell/test_validators.js | 3762 ++++++ .../stringvalidator/tests/xpcshell/xpcshell.ini | 8 + .../storage/vendor/stringvalidator/util/assert.js | 215 + .../storage/vendor/stringvalidator/util/moz.build | 9 + .../storage/vendor/stringvalidator/validator.js | 1489 +++ devtools/shared/system.js | 198 + devtools/shared/test-helpers/allocation-tracker.js | 637 + devtools/shared/test-helpers/browser.ini | 8 + .../test-helpers/browser_allocation_tracker.js | 250 + devtools/shared/test-helpers/moz.build | 13 + .../shared/test-helpers/test_javascript_tracer.js | 71 + .../shared/test-helpers/thread-helpers.sys.mjs | 143 + .../shared/test-helpers/tracked-objects.sys.mjs | 47 + devtools/shared/test-helpers/xpcshell.ini | 6 + devtools/shared/tests/browser/browser.ini | 10 + .../shared/tests/browser/browser_async_storage.js | 76 + .../tests/browser/browser_l10n_localizeMarkup.js | 94 + devtools/shared/tests/chrome/chrome.ini | 8 + .../chrome/test_css-logic-findCssSelector.html | 115 + .../tests/chrome/test_css-logic-getCssPath.html | 106 + .../tests/chrome/test_css-logic-getXPath.html | 95 + devtools/shared/tests/xpcshell/.eslintrc.js | 6 + devtools/shared/tests/xpcshell/exposeLoader.js | 10 + devtools/shared/tests/xpcshell/head_devtools.js | 66 + devtools/shared/tests/xpcshell/test_assert.js | 42 + .../tests/xpcshell/test_console_filtering.js | 156 + .../tests/xpcshell/test_css-properties-db.js | 180 + devtools/shared/tests/xpcshell/test_csslexer.js | 203 + .../shared/tests/xpcshell/test_debugger_client.js | 69 + devtools/shared/tests/xpcshell/test_defer.js | 32 + .../xpcshell/test_defineLazyPrototypeGetter.js | 68 + .../xpcshell/test_eventemitter_abort_controller.js | 181 + .../tests/xpcshell/test_eventemitter_basic.js | 345 + .../tests/xpcshell/test_eventemitter_destroy.js | 32 + .../tests/xpcshell/test_eventemitter_static.js | 378 + devtools/shared/tests/xpcshell/test_executeSoon.js | 35 + devtools/shared/tests/xpcshell/test_fetch-bom.js | 80 + .../shared/tests/xpcshell/test_fetch-chrome.js | 36 + devtools/shared/tests/xpcshell/test_fetch-file.js | 113 + devtools/shared/tests/xpcshell/test_fetch-http.js | 67 + .../shared/tests/xpcshell/test_fetch-resource.js | 43 + devtools/shared/tests/xpcshell/test_flatten.js | 27 + devtools/shared/tests/xpcshell/test_indentation.js | 150 + .../tests/xpcshell/test_independent_loaders.js | 22 + .../shared/tests/xpcshell/test_invisible_loader.js | 80 + devtools/shared/tests/xpcshell/test_isSet.js | 35 + devtools/shared/tests/xpcshell/test_loader.js | 72 + .../shared/tests/xpcshell/test_natural-sort.js | 911 ++ .../tests/xpcshell/test_pluralForm-english.js | 32 + .../tests/xpcshell/test_pluralForm-makeGetter.js | 38 + devtools/shared/tests/xpcshell/test_prettifyCSS.js | 172 + devtools/shared/tests/xpcshell/test_require.js | 100 + .../shared/tests/xpcshell/test_require_lazy.js | 38 + devtools/shared/tests/xpcshell/test_require_raw.js | 26 + .../shared/tests/xpcshell/test_safeErrorString.js | 59 + devtools/shared/tests/xpcshell/test_sprintfjs.js | 120 + devtools/shared/tests/xpcshell/test_stack.js | 49 + .../shared/tests/xpcshell/throwing-module-1.js | 7 + .../shared/tests/xpcshell/throwing-module-2.js | 8 + devtools/shared/tests/xpcshell/xpcshell.ini | 48 + devtools/shared/throttle.js | 77 + devtools/shared/transport/child-transport.js | 128 + .../shared/transport/js-window-actor-transport.js | 66 + devtools/shared/transport/local-transport.js | 204 + devtools/shared/transport/moz.build | 18 + devtools/shared/transport/packets.js | 440 + devtools/shared/transport/stream-utils.js | 254 + .../shared/transport/tests/xpcshell/.eslintrc.js | 6 + .../shared/transport/tests/xpcshell/head_dbg.js | 177 + .../transport/tests/xpcshell/test_bulk_error.js | 94 + .../tests/xpcshell/test_client_server_bulk.js | 312 + .../transport/tests/xpcshell/test_dbgsocket.js | 163 + .../xpcshell/test_dbgsocket_connection_drop.js | 86 + .../tests/xpcshell/test_delimited_read.js | 30 + .../shared/transport/tests/xpcshell/test_packet.js | 24 + .../shared/transport/tests/xpcshell/test_queue.js | 198 + .../tests/xpcshell/test_transport_bulk.js | 169 + .../shared/transport/tests/xpcshell/testactors.js | 16 + .../shared/transport/tests/xpcshell/xpcshell.ini | 17 + devtools/shared/transport/transport.js | 499 + devtools/shared/transport/websocket-transport.js | 86 + devtools/shared/transport/worker-transport.js | 113 + devtools/shared/validate-breakpoint.jsm | 49 + devtools/shared/wasm-source-map.js | 114 + .../shared/webconsole/GenerateDataFromWebIdls.py | 176 + .../shared/webconsole/GenerateReservedWordsJS.py | 40 + devtools/shared/webconsole/analyze-input-string.js | 406 + devtools/shared/webconsole/js-property-provider.js | 803 ++ devtools/shared/webconsole/messages.js | 55 + devtools/shared/webconsole/moz.build | 33 + devtools/shared/webconsole/parser-helper.js | 66 + .../shared/webconsole/test/browser/browser.ini | 13 + .../test/browser/browser_commands_registration.js | 78 + .../test/browser/browser_network_longstring.js | 183 + devtools/shared/webconsole/test/browser/data.json | 3 + .../webconsole/test/browser/data.json^headers^ | 3 + devtools/shared/webconsole/test/browser/head.js | 61 + .../test/browser/network_requests_iframe.html | 66 + devtools/shared/webconsole/test/chrome/chrome.ini | 34 + devtools/shared/webconsole/test/chrome/common.js | 274 + .../webconsole/test/chrome/console-test-worker.js | 21 + devtools/shared/webconsole/test/chrome/data.json | 5 + .../webconsole/test/chrome/data.json^headers^ | 3 + .../webconsole/test/chrome/helper_serviceworker.js | 21 + .../test/chrome/network_requests_iframe.html | 66 + .../webconsole/test/chrome/sandboxed_iframe.html | 8 + .../shared/webconsole/test/chrome/test_basics.html | 61 + .../test/chrome/test_cached_messages.html | 217 + .../test/chrome/test_console_assert.html | 106 + .../test/chrome/test_console_group_styling.html | 121 + .../test/chrome/test_console_serviceworker.html | 202 + .../chrome/test_console_serviceworker_cached.html | 119 + .../test/chrome/test_console_styling.html | 134 + .../test/chrome/test_console_timestamp.html | 48 + .../test/chrome/test_console_worker.html | 73 + .../webconsole/test/chrome/test_consoleapi.html | 225 + .../test/chrome/test_consoleapi_innerID.html | 157 + .../webconsole/test/chrome/test_file_uri.html | 110 + .../test/chrome/test_jsterm_autocomplete.html | 635 + .../webconsole/test/chrome/test_network_get.html | 132 + .../webconsole/test/chrome/test_network_post.html | 143 + .../test/chrome/test_network_security-hsts.html | 89 + .../test/chrome/test_nsiconsolemessage.html | 74 + .../webconsole/test/chrome/test_object_actor.html | 158 + .../chrome/test_object_actor_native_getters.html | 75 + ...t_object_actor_native_getters_lenient_this.html | 54 + .../webconsole/test/chrome/test_page_errors.html | 224 + .../shared/webconsole/test/xpcshell/.eslintrc.js | 6 + devtools/shared/webconsole/test/xpcshell/head.js | 10 + .../test/xpcshell/test_analyze_input_string.js | 225 + .../test/xpcshell/test_js_property_provider.js | 746 ++ .../shared/webconsole/test/xpcshell/xpcshell.ini | 9 + devtools/shared/webextension-fallback.html | 6 + devtools/shared/worker/helper.js | 138 + devtools/shared/worker/moz.build | 13 + devtools/shared/worker/tests/browser/browser.ini | 9 + .../worker/tests/browser/browser_worker-01.js | 110 + .../worker/tests/browser/browser_worker-02.js | 83 + .../worker/tests/browser/browser_worker-03.js | 63 + devtools/shared/worker/worker.js | 198 + 720 files changed, 125182 insertions(+) create mode 100644 devtools/shared/.eslintrc.js create mode 100644 devtools/shared/DevToolsInfaillibleUtils.sys.mjs create mode 100644 devtools/shared/DevToolsUtils.js create mode 100644 devtools/shared/ThreadSafeDevToolsUtils.js create mode 100644 devtools/shared/accessibility.js create mode 100644 devtools/shared/async-storage.js create mode 100644 devtools/shared/async-utils.js create mode 100644 devtools/shared/commands/README.md create mode 100644 devtools/shared/commands/commands-factory.js 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.ini 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.ini 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.ini 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/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.ini 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_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_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.ini 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.ini 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.ini 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.ini 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/compatibility/README.md create mode 100644 devtools/shared/compatibility/bin/update.js create mode 100644 devtools/shared/compatibility/compatibility-user-settings.js create mode 100644 devtools/shared/compatibility/constants.js create mode 100644 devtools/shared/compatibility/dataset/css-properties.json create mode 100644 devtools/shared/compatibility/dataset/moz.build create mode 100644 devtools/shared/compatibility/helpers.js create mode 100644 devtools/shared/compatibility/moz.build create mode 100644 devtools/shared/compatibility/package.json create mode 100644 devtools/shared/constants.js create mode 100644 devtools/shared/content-observer.js create mode 100644 devtools/shared/css/color-db.js create mode 100644 devtools/shared/css/color.js create mode 100644 devtools/shared/css/constants.js create mode 100644 devtools/shared/css/generated/generate-properties-db.js create mode 100644 devtools/shared/css/generated/mach_commands.py create mode 100644 devtools/shared/css/generated/moz.build create mode 100644 devtools/shared/css/generated/properties-db.js create mode 100644 devtools/shared/css/generated/properties-db.js.in create mode 100644 devtools/shared/css/lexer.js create mode 100644 devtools/shared/css/moz.build create mode 100644 devtools/shared/css/parsing-utils.js create mode 100644 devtools/shared/css/properties-db.js create mode 100644 devtools/shared/debounce.js create mode 100644 devtools/shared/defer.js create mode 100644 devtools/shared/discovery/discovery.js create mode 100644 devtools/shared/discovery/moz.build create mode 100644 devtools/shared/discovery/tests/xpcshell/.eslintrc.js create mode 100644 devtools/shared/discovery/tests/xpcshell/test_discovery.js create mode 100644 devtools/shared/discovery/tests/xpcshell/xpcshell.ini create mode 100644 devtools/shared/dom-helpers.js create mode 100644 devtools/shared/dom-node-constants.js create mode 100644 devtools/shared/dom-node-filter-constants.js create mode 100644 devtools/shared/event-emitter.js create mode 100644 devtools/shared/extend.js create mode 100644 devtools/shared/flags.js create mode 100644 devtools/shared/generate-uuid.js create mode 100644 devtools/shared/heapsnapshot/AutoMemMap.cpp create mode 100644 devtools/shared/heapsnapshot/AutoMemMap.h create mode 100644 devtools/shared/heapsnapshot/CensusUtils.js create mode 100644 devtools/shared/heapsnapshot/CoreDump.pb.cc create mode 100644 devtools/shared/heapsnapshot/CoreDump.pb.h create mode 100644 devtools/shared/heapsnapshot/CoreDump.proto create mode 100644 devtools/shared/heapsnapshot/DeserializedNode.cpp create mode 100644 devtools/shared/heapsnapshot/DeserializedNode.h create mode 100644 devtools/shared/heapsnapshot/DominatorTree.cpp create mode 100644 devtools/shared/heapsnapshot/DominatorTree.h create mode 100644 devtools/shared/heapsnapshot/DominatorTreeNode.js create mode 100644 devtools/shared/heapsnapshot/FileDescriptorOutputStream.cpp create mode 100644 devtools/shared/heapsnapshot/FileDescriptorOutputStream.h create mode 100644 devtools/shared/heapsnapshot/HeapAnalysesClient.js create mode 100644 devtools/shared/heapsnapshot/HeapAnalysesWorker.js create mode 100644 devtools/shared/heapsnapshot/HeapSnapshot.cpp create mode 100644 devtools/shared/heapsnapshot/HeapSnapshot.h create mode 100644 devtools/shared/heapsnapshot/HeapSnapshotFileUtils.js create mode 100644 devtools/shared/heapsnapshot/HeapSnapshotTempFileHelperChild.h create mode 100644 devtools/shared/heapsnapshot/HeapSnapshotTempFileHelperParent.cpp create mode 100644 devtools/shared/heapsnapshot/HeapSnapshotTempFileHelperParent.h create mode 100644 devtools/shared/heapsnapshot/PHeapSnapshotTempFileHelper.ipdl create mode 100644 devtools/shared/heapsnapshot/ZeroCopyNSIOutputStream.cpp create mode 100644 devtools/shared/heapsnapshot/ZeroCopyNSIOutputStream.h create mode 100644 devtools/shared/heapsnapshot/census-tree-node.js create mode 100755 devtools/shared/heapsnapshot/generate-core-dump-sources.sh create mode 100644 devtools/shared/heapsnapshot/moz.build create mode 100644 devtools/shared/heapsnapshot/shortest-paths.js create mode 100644 devtools/shared/heapsnapshot/tests/browser/browser.ini create mode 100644 devtools/shared/heapsnapshot/tests/browser/browser_saveHeapSnapshot_e10s_01.js create mode 100644 devtools/shared/heapsnapshot/tests/chrome/chrome.ini create mode 100644 devtools/shared/heapsnapshot/tests/chrome/test_DominatorTree_01.html create mode 100644 devtools/shared/heapsnapshot/tests/chrome/test_SaveHeapSnapshot.html create mode 100644 devtools/shared/heapsnapshot/tests/gtest/DeserializedNodeUbiNodes.cpp create mode 100644 devtools/shared/heapsnapshot/tests/gtest/DeserializedStackFrameUbiStackFrames.cpp create mode 100644 devtools/shared/heapsnapshot/tests/gtest/DevTools.cpp create mode 100644 devtools/shared/heapsnapshot/tests/gtest/DevTools.h create mode 100644 devtools/shared/heapsnapshot/tests/gtest/DoesCrossCompartmentBoundaries.cpp create mode 100644 devtools/shared/heapsnapshot/tests/gtest/DoesntCrossCompartmentBoundaries.cpp create mode 100644 devtools/shared/heapsnapshot/tests/gtest/SerializesEdgeNames.cpp create mode 100644 devtools/shared/heapsnapshot/tests/gtest/SerializesEverythingInHeapGraphOnce.cpp create mode 100644 devtools/shared/heapsnapshot/tests/gtest/SerializesTypeNames.cpp create mode 100644 devtools/shared/heapsnapshot/tests/gtest/moz.build create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/.eslintrc.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/Census.sys.mjs create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/Match.sys.mjs create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/dominator-tree-worker.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/head_heapsnapshot.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/heap-snapshot-worker.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTreeNode_LabelAndShallowSize_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTreeNode_LabelAndShallowSize_02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTreeNode_LabelAndShallowSize_03.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTreeNode_LabelAndShallowSize_04.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTreeNode_attachShortestPaths_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTreeNode_getNodeByIdAlongPath_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTreeNode_insert_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTreeNode_insert_02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTreeNode_insert_03.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTreeNode_partialTraversal_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTree_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTree_02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTree_03.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTree_04.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTree_05.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_DominatorTree_06.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_computeDominatorTree_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_computeDominatorTree_02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_deleteHeapSnapshot_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_deleteHeapSnapshot_02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_deleteHeapSnapshot_03.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_getCensusIndividuals_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_getCreationTime_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_getDominatorTree_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_getDominatorTree_02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_getImmediatelyDominated_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_readHeapSnapshot_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_takeCensusDiff_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_takeCensusDiff_02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_takeCensus_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_takeCensus_02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_takeCensus_03.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_takeCensus_04.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_takeCensus_05.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_takeCensus_06.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapAnalyses_takeCensus_07.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_computeShortestPaths_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_computeShortestPaths_02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_creationTime_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_deepStack_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_describeNode_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_getObjectNodeId_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_takeCensus_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_takeCensus_02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_takeCensus_03.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_takeCensus_04.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_takeCensus_05.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_takeCensus_06.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_takeCensus_07.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_takeCensus_08.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_takeCensus_09.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_takeCensus_10.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_takeCensus_11.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_HeapSnapshot_takeCensus_12.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_ReadHeapSnapshot.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_ReadHeapSnapshot_with_allocations.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_ReadHeapSnapshot_with_utf8_paths.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_ReadHeapSnapshot_worker.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_SaveHeapSnapshot.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census-tree-node-01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census-tree-node-02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census-tree-node-03.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census-tree-node-04.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census-tree-node-05.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census-tree-node-06.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census-tree-node-07.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census-tree-node-08.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census-tree-node-09.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census-tree-node-10.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census_diff_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census_diff_02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census_diff_03.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census_diff_04.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census_diff_05.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census_diff_06.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census_filtering_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census_filtering_02.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census_filtering_03.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census_filtering_04.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_census_filtering_05.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_countToBucketBreakdown_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_deduplicatePaths_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_getCensusIndividuals_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_getReportLeaves_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/test_saveHeapSnapshot_e10s_01.js create mode 100644 devtools/shared/heapsnapshot/tests/xpcshell/xpcshell.ini create mode 100644 devtools/shared/images/command-pick-remote-touch.svg create mode 100644 devtools/shared/images/command-pick.svg create mode 100644 devtools/shared/images/resume.svg create mode 100644 devtools/shared/images/stepOver.svg create mode 100644 devtools/shared/indentation.js create mode 100644 devtools/shared/indexed-db.js create mode 100644 devtools/shared/inspector/css-logic.js create mode 100644 devtools/shared/inspector/moz.build create mode 100644 devtools/shared/inspector/utils.js create mode 100644 devtools/shared/jar.mn create mode 100644 devtools/shared/jsbeautify/UPGRADING.md create mode 100644 devtools/shared/jsbeautify/beautify.js create mode 100644 devtools/shared/jsbeautify/moz.build create mode 100644 devtools/shared/jsbeautify/src/beautify-css.js create mode 100644 devtools/shared/jsbeautify/src/beautify-html.js create mode 100644 devtools/shared/jsbeautify/src/beautify-js.js create mode 100644 devtools/shared/jsbeautify/src/moz.build create mode 100644 devtools/shared/l10n.js create mode 100644 devtools/shared/layout/dom-matrix-2d.js create mode 100644 devtools/shared/layout/moz.build create mode 100644 devtools/shared/layout/utils.js create mode 100644 devtools/shared/loader/DistinctSystemPrincipalLoader.sys.mjs create mode 100644 devtools/shared/loader/Loader.sys.mjs create mode 100644 devtools/shared/loader/base-loader.sys.mjs create mode 100644 devtools/shared/loader/browser-loader-mocks.js create mode 100644 devtools/shared/loader/browser-loader.js create mode 100644 devtools/shared/loader/builtin-modules.js create mode 100644 devtools/shared/loader/loader-plugin-raw.sys.mjs create mode 100644 devtools/shared/loader/moz.build create mode 100644 devtools/shared/loader/worker-loader.js create mode 100644 devtools/shared/locales/en-US/accessibility.properties create mode 100644 devtools/shared/locales/en-US/debugger-paused-reasons.ftl create mode 100644 devtools/shared/locales/en-US/debugger.properties create mode 100644 devtools/shared/locales/en-US/eyedropper.properties create mode 100644 devtools/shared/locales/en-US/highlighters.ftl create mode 100644 devtools/shared/locales/en-US/screenshot.properties create mode 100644 devtools/shared/locales/en-US/shared.properties create mode 100644 devtools/shared/locales/en-US/styleinspector.properties create mode 100644 devtools/shared/locales/jar.mn create mode 100644 devtools/shared/locales/l10n.toml create mode 100644 devtools/shared/locales/moz.build create mode 100644 devtools/shared/moz.build create mode 100644 devtools/shared/natural-sort.js create mode 100644 devtools/shared/network-observer/ChannelMap.sys.mjs create mode 100644 devtools/shared/network-observer/NetworkHelper.sys.mjs create mode 100644 devtools/shared/network-observer/NetworkObserver.sys.mjs create mode 100644 devtools/shared/network-observer/NetworkOverride.sys.mjs create mode 100644 devtools/shared/network-observer/NetworkResponseListener.sys.mjs create mode 100644 devtools/shared/network-observer/NetworkThrottleManager.sys.mjs create mode 100644 devtools/shared/network-observer/NetworkUtils.sys.mjs create mode 100644 devtools/shared/network-observer/README.md create mode 100644 devtools/shared/network-observer/WildcardToRegexp.sys.mjs create mode 100644 devtools/shared/network-observer/moz.build create mode 100644 devtools/shared/network-observer/test/browser/browser.ini create mode 100644 devtools/shared/network-observer/test/browser/browser_networkobserver.js create mode 100644 devtools/shared/network-observer/test/browser/browser_networkobserver_invalid_constructor.js create mode 100644 devtools/shared/network-observer/test/browser/browser_networkobserver_override.js create mode 100644 devtools/shared/network-observer/test/browser/doc_network-observer.html create mode 100644 devtools/shared/network-observer/test/browser/gzipped.sjs create mode 100644 devtools/shared/network-observer/test/browser/head.js create mode 100644 devtools/shared/network-observer/test/browser/override.html create mode 100644 devtools/shared/network-observer/test/browser/override.js create mode 100644 devtools/shared/network-observer/test/browser/sjs_network-observer-test-server.sjs create mode 100644 devtools/shared/network-observer/test/xpcshell/head.js create mode 100644 devtools/shared/network-observer/test/xpcshell/test_network_helper.js create mode 100644 devtools/shared/network-observer/test/xpcshell/test_security-info-certificate.js create mode 100644 devtools/shared/network-observer/test/xpcshell/test_security-info-parser.js create mode 100644 devtools/shared/network-observer/test/xpcshell/test_security-info-protocol-version.js create mode 100644 devtools/shared/network-observer/test/xpcshell/test_security-info-state.js create mode 100644 devtools/shared/network-observer/test/xpcshell/test_security-info-static-hpkp.js create mode 100644 devtools/shared/network-observer/test/xpcshell/test_security-info-weakness-reasons.js create mode 100644 devtools/shared/network-observer/test/xpcshell/test_throttle.js create mode 100644 devtools/shared/network-observer/test/xpcshell/xpcshell.ini create mode 100644 devtools/shared/node-properties/UPGRADING.md create mode 100644 devtools/shared/node-properties/moz.build create mode 100644 devtools/shared/node-properties/node-properties.js create mode 100644 devtools/shared/path.js create mode 100644 devtools/shared/performance-new/moz.build create mode 100644 devtools/shared/performance-new/recording-utils.js create mode 100644 devtools/shared/picker-constants.js create mode 100644 devtools/shared/platform/CacheEntry.sys.mjs create mode 100644 devtools/shared/platform/clipboard.js create mode 100644 devtools/shared/platform/moz.build create mode 100644 devtools/shared/platform/stack.js create mode 100644 devtools/shared/plural-form.js create mode 100644 devtools/shared/protocol.js create mode 100644 devtools/shared/protocol/Actor.js create mode 100644 devtools/shared/protocol/Actor/generateActorSpec.js create mode 100644 devtools/shared/protocol/Actor/moz.build create mode 100644 devtools/shared/protocol/Front.js create mode 100644 devtools/shared/protocol/Front/FrontClassWithSpec.js create mode 100644 devtools/shared/protocol/Front/moz.build create mode 100644 devtools/shared/protocol/Pool.js create mode 100644 devtools/shared/protocol/Request.js create mode 100644 devtools/shared/protocol/Response.js create mode 100644 devtools/shared/protocol/lazy-pool.js create mode 100644 devtools/shared/protocol/moz.build create mode 100644 devtools/shared/protocol/tests/xpcshell/.eslintrc.js create mode 100644 devtools/shared/protocol/tests/xpcshell/head.js create mode 100644 devtools/shared/protocol/tests/xpcshell/test_protocol_abort.js create mode 100644 devtools/shared/protocol/tests/xpcshell/test_protocol_async.js create mode 100644 devtools/shared/protocol/tests/xpcshell/test_protocol_children.js create mode 100644 devtools/shared/protocol/tests/xpcshell/test_protocol_index.js create mode 100644 devtools/shared/protocol/tests/xpcshell/test_protocol_invalid_response.js create mode 100644 devtools/shared/protocol/tests/xpcshell/test_protocol_lifecycle.js create mode 100644 devtools/shared/protocol/tests/xpcshell/test_protocol_longstring.js create mode 100644 devtools/shared/protocol/tests/xpcshell/test_protocol_simple.js create mode 100644 devtools/shared/protocol/tests/xpcshell/test_protocol_stack.js create mode 100644 devtools/shared/protocol/tests/xpcshell/test_protocol_types.js create mode 100644 devtools/shared/protocol/tests/xpcshell/test_protocol_unregister.js create mode 100644 devtools/shared/protocol/tests/xpcshell/test_protocol_watchFronts.js create mode 100644 devtools/shared/protocol/tests/xpcshell/xpcshell.ini create mode 100644 devtools/shared/protocol/types.js create mode 100644 devtools/shared/protocol/utils.js create mode 100644 devtools/shared/qrcode/decoder/LICENSE create mode 100644 devtools/shared/qrcode/decoder/index.js create mode 100644 devtools/shared/qrcode/decoder/moz.build create mode 100644 devtools/shared/qrcode/encoder/LICENSE create mode 100644 devtools/shared/qrcode/encoder/index.js create mode 100644 devtools/shared/qrcode/encoder/moz.build create mode 100644 devtools/shared/qrcode/index.js create mode 100644 devtools/shared/qrcode/moz.build create mode 100644 devtools/shared/qrcode/tests/chrome/chrome.ini create mode 100644 devtools/shared/qrcode/tests/chrome/test_decode.html create mode 100644 devtools/shared/qrcode/tests/xpcshell/.eslintrc.js create mode 100644 devtools/shared/qrcode/tests/xpcshell/test_encode.js create mode 100644 devtools/shared/qrcode/tests/xpcshell/xpcshell.ini create mode 100644 devtools/shared/security/DevToolsSocketStatus.sys.mjs create mode 100644 devtools/shared/security/auth.js create mode 100644 devtools/shared/security/moz.build create mode 100644 devtools/shared/security/prompt.js create mode 100644 devtools/shared/security/socket.js create mode 100644 devtools/shared/security/tests/chrome/chrome.ini create mode 100644 devtools/shared/security/tests/chrome/test_websocket-transport.html create mode 100644 devtools/shared/security/tests/xpcshell/.eslintrc.js create mode 100644 devtools/shared/security/tests/xpcshell/head_dbg.js create mode 100644 devtools/shared/security/tests/xpcshell/test_devtools_socket_status.js create mode 100644 devtools/shared/security/tests/xpcshell/testactors.js create mode 100644 devtools/shared/security/tests/xpcshell/xpcshell.ini create mode 100644 devtools/shared/specs/accessibility.js create mode 100644 devtools/shared/specs/addon/addons.js create mode 100644 devtools/shared/specs/addon/moz.build create mode 100644 devtools/shared/specs/addon/webextension-inspected-window.js create mode 100644 devtools/shared/specs/animation.js create mode 100644 devtools/shared/specs/array-buffer.js create mode 100644 devtools/shared/specs/blackboxing.js create mode 100644 devtools/shared/specs/breakpoint-list.js create mode 100644 devtools/shared/specs/changes.js create mode 100644 devtools/shared/specs/compatibility.js create mode 100644 devtools/shared/specs/css-properties.js create mode 100644 devtools/shared/specs/descriptors/moz.build create mode 100644 devtools/shared/specs/descriptors/process.js create mode 100644 devtools/shared/specs/descriptors/tab.js create mode 100644 devtools/shared/specs/descriptors/webextension.js create mode 100644 devtools/shared/specs/descriptors/worker.js create mode 100644 devtools/shared/specs/device.js create mode 100644 devtools/shared/specs/environment.js create mode 100644 devtools/shared/specs/frame.js create mode 100644 devtools/shared/specs/heap-snapshot-file.js create mode 100644 devtools/shared/specs/highlighters.js create mode 100644 devtools/shared/specs/index.js create mode 100644 devtools/shared/specs/inspector.js create mode 100644 devtools/shared/specs/layout.js create mode 100644 devtools/shared/specs/manifest.js create mode 100644 devtools/shared/specs/memory.js create mode 100644 devtools/shared/specs/moz.build create mode 100644 devtools/shared/specs/network-content.js create mode 100644 devtools/shared/specs/network-event.js create mode 100644 devtools/shared/specs/network-parent.js create mode 100644 devtools/shared/specs/node.js create mode 100644 devtools/shared/specs/object.js create mode 100644 devtools/shared/specs/page-style.js create mode 100644 devtools/shared/specs/perf.js create mode 100644 devtools/shared/specs/preference.js create mode 100644 devtools/shared/specs/private-properties-iterator.js create mode 100644 devtools/shared/specs/property-iterator.js create mode 100644 devtools/shared/specs/reflow.js create mode 100644 devtools/shared/specs/responsive.js create mode 100644 devtools/shared/specs/root.js create mode 100644 devtools/shared/specs/screenshot-content.js create mode 100644 devtools/shared/specs/screenshot.js create mode 100644 devtools/shared/specs/source.js create mode 100644 devtools/shared/specs/storage.js create mode 100644 devtools/shared/specs/string.js create mode 100644 devtools/shared/specs/style-rule.js create mode 100644 devtools/shared/specs/style-sheets.js create mode 100644 devtools/shared/specs/style/moz.build create mode 100644 devtools/shared/specs/style/style-types.js create mode 100644 devtools/shared/specs/symbol-iterator.js create mode 100644 devtools/shared/specs/symbol.js create mode 100644 devtools/shared/specs/target-configuration.js create mode 100644 devtools/shared/specs/targets/content-process.js create mode 100644 devtools/shared/specs/targets/moz.build create mode 100644 devtools/shared/specs/targets/parent-process.js create mode 100644 devtools/shared/specs/targets/webextension.js create mode 100644 devtools/shared/specs/targets/window-global.js create mode 100644 devtools/shared/specs/targets/worker.js create mode 100644 devtools/shared/specs/thread-configuration.js create mode 100644 devtools/shared/specs/thread.js create mode 100644 devtools/shared/specs/tracer.js create mode 100644 devtools/shared/specs/walker.js create mode 100644 devtools/shared/specs/watcher.js create mode 100644 devtools/shared/specs/webconsole.js create mode 100644 devtools/shared/specs/worker/moz.build create mode 100644 devtools/shared/specs/worker/push-subscription.js create mode 100644 devtools/shared/specs/worker/service-worker-registration.js create mode 100644 devtools/shared/specs/worker/service-worker.js create mode 100644 devtools/shared/sprintfjs/UPGRADING.md create mode 100644 devtools/shared/sprintfjs/moz.build create mode 100644 devtools/shared/sprintfjs/sprintf.js create mode 100644 devtools/shared/storage/moz.build create mode 100644 devtools/shared/storage/utils.js create mode 100644 devtools/shared/storage/vendor/JSON5_LICENSE create mode 100644 devtools/shared/storage/vendor/JSON5_UPGRADING.md create mode 100644 devtools/shared/storage/vendor/json5.js create mode 100644 devtools/shared/storage/vendor/moz.build create mode 100644 devtools/shared/storage/vendor/stringvalidator/UPDATING.md create mode 100644 devtools/shared/storage/vendor/stringvalidator/moz.build create mode 100644 devtools/shared/storage/vendor/stringvalidator/tests/xpcshell/head_stringvalidator.js create mode 100644 devtools/shared/storage/vendor/stringvalidator/tests/xpcshell/test_sanitizers.js create mode 100644 devtools/shared/storage/vendor/stringvalidator/tests/xpcshell/test_validators.js create mode 100644 devtools/shared/storage/vendor/stringvalidator/tests/xpcshell/xpcshell.ini create mode 100644 devtools/shared/storage/vendor/stringvalidator/util/assert.js create mode 100644 devtools/shared/storage/vendor/stringvalidator/util/moz.build create mode 100644 devtools/shared/storage/vendor/stringvalidator/validator.js create mode 100644 devtools/shared/system.js create mode 100644 devtools/shared/test-helpers/allocation-tracker.js create mode 100644 devtools/shared/test-helpers/browser.ini create mode 100644 devtools/shared/test-helpers/browser_allocation_tracker.js create mode 100644 devtools/shared/test-helpers/moz.build create mode 100644 devtools/shared/test-helpers/test_javascript_tracer.js create mode 100644 devtools/shared/test-helpers/thread-helpers.sys.mjs create mode 100644 devtools/shared/test-helpers/tracked-objects.sys.mjs create mode 100644 devtools/shared/test-helpers/xpcshell.ini create mode 100644 devtools/shared/tests/browser/browser.ini create mode 100644 devtools/shared/tests/browser/browser_async_storage.js create mode 100644 devtools/shared/tests/browser/browser_l10n_localizeMarkup.js create mode 100644 devtools/shared/tests/chrome/chrome.ini create mode 100644 devtools/shared/tests/chrome/test_css-logic-findCssSelector.html create mode 100644 devtools/shared/tests/chrome/test_css-logic-getCssPath.html create mode 100644 devtools/shared/tests/chrome/test_css-logic-getXPath.html create mode 100644 devtools/shared/tests/xpcshell/.eslintrc.js create mode 100644 devtools/shared/tests/xpcshell/exposeLoader.js create mode 100644 devtools/shared/tests/xpcshell/head_devtools.js create mode 100644 devtools/shared/tests/xpcshell/test_assert.js create mode 100644 devtools/shared/tests/xpcshell/test_console_filtering.js create mode 100644 devtools/shared/tests/xpcshell/test_css-properties-db.js create mode 100644 devtools/shared/tests/xpcshell/test_csslexer.js create mode 100644 devtools/shared/tests/xpcshell/test_debugger_client.js create mode 100644 devtools/shared/tests/xpcshell/test_defer.js create mode 100644 devtools/shared/tests/xpcshell/test_defineLazyPrototypeGetter.js create mode 100644 devtools/shared/tests/xpcshell/test_eventemitter_abort_controller.js create mode 100644 devtools/shared/tests/xpcshell/test_eventemitter_basic.js create mode 100644 devtools/shared/tests/xpcshell/test_eventemitter_destroy.js create mode 100644 devtools/shared/tests/xpcshell/test_eventemitter_static.js create mode 100644 devtools/shared/tests/xpcshell/test_executeSoon.js create mode 100644 devtools/shared/tests/xpcshell/test_fetch-bom.js create mode 100644 devtools/shared/tests/xpcshell/test_fetch-chrome.js create mode 100644 devtools/shared/tests/xpcshell/test_fetch-file.js create mode 100644 devtools/shared/tests/xpcshell/test_fetch-http.js create mode 100644 devtools/shared/tests/xpcshell/test_fetch-resource.js create mode 100644 devtools/shared/tests/xpcshell/test_flatten.js create mode 100644 devtools/shared/tests/xpcshell/test_indentation.js create mode 100644 devtools/shared/tests/xpcshell/test_independent_loaders.js create mode 100644 devtools/shared/tests/xpcshell/test_invisible_loader.js create mode 100644 devtools/shared/tests/xpcshell/test_isSet.js create mode 100644 devtools/shared/tests/xpcshell/test_loader.js create mode 100644 devtools/shared/tests/xpcshell/test_natural-sort.js create mode 100644 devtools/shared/tests/xpcshell/test_pluralForm-english.js create mode 100644 devtools/shared/tests/xpcshell/test_pluralForm-makeGetter.js create mode 100644 devtools/shared/tests/xpcshell/test_prettifyCSS.js create mode 100644 devtools/shared/tests/xpcshell/test_require.js create mode 100644 devtools/shared/tests/xpcshell/test_require_lazy.js create mode 100644 devtools/shared/tests/xpcshell/test_require_raw.js create mode 100644 devtools/shared/tests/xpcshell/test_safeErrorString.js create mode 100644 devtools/shared/tests/xpcshell/test_sprintfjs.js create mode 100644 devtools/shared/tests/xpcshell/test_stack.js create mode 100644 devtools/shared/tests/xpcshell/throwing-module-1.js create mode 100644 devtools/shared/tests/xpcshell/throwing-module-2.js create mode 100644 devtools/shared/tests/xpcshell/xpcshell.ini create mode 100644 devtools/shared/throttle.js create mode 100644 devtools/shared/transport/child-transport.js create mode 100644 devtools/shared/transport/js-window-actor-transport.js create mode 100644 devtools/shared/transport/local-transport.js create mode 100644 devtools/shared/transport/moz.build create mode 100644 devtools/shared/transport/packets.js create mode 100644 devtools/shared/transport/stream-utils.js create mode 100644 devtools/shared/transport/tests/xpcshell/.eslintrc.js create mode 100644 devtools/shared/transport/tests/xpcshell/head_dbg.js create mode 100644 devtools/shared/transport/tests/xpcshell/test_bulk_error.js create mode 100644 devtools/shared/transport/tests/xpcshell/test_client_server_bulk.js create mode 100644 devtools/shared/transport/tests/xpcshell/test_dbgsocket.js create mode 100644 devtools/shared/transport/tests/xpcshell/test_dbgsocket_connection_drop.js create mode 100644 devtools/shared/transport/tests/xpcshell/test_delimited_read.js create mode 100644 devtools/shared/transport/tests/xpcshell/test_packet.js create mode 100644 devtools/shared/transport/tests/xpcshell/test_queue.js create mode 100644 devtools/shared/transport/tests/xpcshell/test_transport_bulk.js create mode 100644 devtools/shared/transport/tests/xpcshell/testactors.js create mode 100644 devtools/shared/transport/tests/xpcshell/xpcshell.ini create mode 100644 devtools/shared/transport/transport.js create mode 100644 devtools/shared/transport/websocket-transport.js create mode 100644 devtools/shared/transport/worker-transport.js create mode 100644 devtools/shared/validate-breakpoint.jsm create mode 100644 devtools/shared/wasm-source-map.js create mode 100644 devtools/shared/webconsole/GenerateDataFromWebIdls.py create mode 100644 devtools/shared/webconsole/GenerateReservedWordsJS.py create mode 100644 devtools/shared/webconsole/analyze-input-string.js create mode 100644 devtools/shared/webconsole/js-property-provider.js create mode 100644 devtools/shared/webconsole/messages.js create mode 100644 devtools/shared/webconsole/moz.build create mode 100644 devtools/shared/webconsole/parser-helper.js create mode 100644 devtools/shared/webconsole/test/browser/browser.ini create mode 100644 devtools/shared/webconsole/test/browser/browser_commands_registration.js create mode 100644 devtools/shared/webconsole/test/browser/browser_network_longstring.js create mode 100644 devtools/shared/webconsole/test/browser/data.json create mode 100644 devtools/shared/webconsole/test/browser/data.json^headers^ create mode 100644 devtools/shared/webconsole/test/browser/head.js create mode 100644 devtools/shared/webconsole/test/browser/network_requests_iframe.html create mode 100644 devtools/shared/webconsole/test/chrome/chrome.ini create mode 100644 devtools/shared/webconsole/test/chrome/common.js create mode 100644 devtools/shared/webconsole/test/chrome/console-test-worker.js create mode 100644 devtools/shared/webconsole/test/chrome/data.json create mode 100644 devtools/shared/webconsole/test/chrome/data.json^headers^ create mode 100644 devtools/shared/webconsole/test/chrome/helper_serviceworker.js create mode 100644 devtools/shared/webconsole/test/chrome/network_requests_iframe.html create mode 100644 devtools/shared/webconsole/test/chrome/sandboxed_iframe.html create mode 100644 devtools/shared/webconsole/test/chrome/test_basics.html create mode 100644 devtools/shared/webconsole/test/chrome/test_cached_messages.html create mode 100644 devtools/shared/webconsole/test/chrome/test_console_assert.html create mode 100644 devtools/shared/webconsole/test/chrome/test_console_group_styling.html create mode 100644 devtools/shared/webconsole/test/chrome/test_console_serviceworker.html create mode 100644 devtools/shared/webconsole/test/chrome/test_console_serviceworker_cached.html create mode 100644 devtools/shared/webconsole/test/chrome/test_console_styling.html create mode 100644 devtools/shared/webconsole/test/chrome/test_console_timestamp.html create mode 100644 devtools/shared/webconsole/test/chrome/test_console_worker.html create mode 100644 devtools/shared/webconsole/test/chrome/test_consoleapi.html create mode 100644 devtools/shared/webconsole/test/chrome/test_consoleapi_innerID.html create mode 100644 devtools/shared/webconsole/test/chrome/test_file_uri.html create mode 100644 devtools/shared/webconsole/test/chrome/test_jsterm_autocomplete.html create mode 100644 devtools/shared/webconsole/test/chrome/test_network_get.html create mode 100644 devtools/shared/webconsole/test/chrome/test_network_post.html create mode 100644 devtools/shared/webconsole/test/chrome/test_network_security-hsts.html create mode 100644 devtools/shared/webconsole/test/chrome/test_nsiconsolemessage.html create mode 100644 devtools/shared/webconsole/test/chrome/test_object_actor.html create mode 100644 devtools/shared/webconsole/test/chrome/test_object_actor_native_getters.html create mode 100644 devtools/shared/webconsole/test/chrome/test_object_actor_native_getters_lenient_this.html create mode 100644 devtools/shared/webconsole/test/chrome/test_page_errors.html create mode 100644 devtools/shared/webconsole/test/xpcshell/.eslintrc.js create mode 100644 devtools/shared/webconsole/test/xpcshell/head.js create mode 100644 devtools/shared/webconsole/test/xpcshell/test_analyze_input_string.js create mode 100644 devtools/shared/webconsole/test/xpcshell/test_js_property_provider.js create mode 100644 devtools/shared/webconsole/test/xpcshell/xpcshell.ini create mode 100644 devtools/shared/webextension-fallback.html create mode 100644 devtools/shared/worker/helper.js create mode 100644 devtools/shared/worker/moz.build create mode 100644 devtools/shared/worker/tests/browser/browser.ini create mode 100644 devtools/shared/worker/tests/browser/browser_worker-01.js create mode 100644 devtools/shared/worker/tests/browser/browser_worker-02.js create mode 100644 devtools/shared/worker/tests/browser/browser_worker-03.js create mode 100644 devtools/shared/worker/worker.js (limited to 'devtools/shared') diff --git a/devtools/shared/.eslintrc.js b/devtools/shared/.eslintrc.js new file mode 100644 index 0000000000..1ec0251734 --- /dev/null +++ b/devtools/shared/.eslintrc.js @@ -0,0 +1,13 @@ +/* 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"; + +module.exports = { + rules: { + // See bug 1288147, the devtools front-end wants to be able to run in + // content privileged windows, where ownerGlobal doesn't exist. + "mozilla/use-ownerGlobal": "off", + }, +}; diff --git a/devtools/shared/DevToolsInfaillibleUtils.sys.mjs b/devtools/shared/DevToolsInfaillibleUtils.sys.mjs new file mode 100644 index 0000000000..ddb2b170ee --- /dev/null +++ b/devtools/shared/DevToolsInfaillibleUtils.sys.mjs @@ -0,0 +1,101 @@ +/* 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/. */ + +/** + * The 3 methods here are duplicated from ThreadSafeDevToolsUtils.js + * The ones defined here are used from other sys.mjs files, mostly from the + * NetworkObserver codebase, while the ones remaining in ThreadSafeDevToolsUtils.js + * are used in commonjs modules, including modules which can be loaded in workers. + * + * sys.mjs modules are currently not supported in workers, see Bug 1247687. + */ + +/** + * Report that |who| threw an exception, |exception|. + */ +function reportException(who, exception) { + const msg = `${who} threw an exception: ${safeErrorString(exception)}`; + dump(msg + "\n"); + + if (typeof console !== "undefined" && console && console.error) { + console.error(exception); + } +} + +/** + * Given a handler function that may throw, return an infallible handler + * function that calls the fallible handler, and logs any exceptions it + * throws. + * + * @param handler function + * A handler function, which may throw. + * @param aName string + * A name for handler, for use in error messages. If omitted, we use + * handler.name. + * + * (SpiderMonkey does generate good names for anonymous functions, but we + * don't have a way to get at them from JavaScript at the moment.) + */ +function makeInfallible(handler, name = handler.name) { + return function () { + try { + return handler.apply(this, arguments); + } catch (ex) { + let who = "Handler function"; + if (name) { + who += " " + name; + } + reportException(who, ex); + return undefined; + } + }; +} + +/** + * Turn the |error| into a string, without fail. + * + * @param {Error|any} error + */ +function safeErrorString(error) { + try { + let errorString = error.toString(); + if (typeof errorString == "string") { + // Attempt to attach a stack to |errorString|. If it throws an error, or + // isn't a string, don't use it. + try { + if (error.stack) { + const stack = error.stack.toString(); + if (typeof stack == "string") { + errorString += "\nStack: " + stack; + } + } + } catch (ee) { + // Ignore. + } + + // Append additional line and column number information to the output, + // since it might not be part of the stringified error. + if ( + typeof error.lineNumber == "number" && + typeof error.columnNumber == "number" + ) { + errorString += + "Line: " + error.lineNumber + ", column: " + error.columnNumber; + } + + return errorString; + } + } catch (ee) { + // Ignore. + } + + // We failed to find a good error description, so do the next best thing. + return Object.prototype.toString.call(error); +} + +export const DevToolsInfaillibleUtils = { + makeInfallible, + reportException, + safeErrorString, +}; diff --git a/devtools/shared/DevToolsUtils.js b/devtools/shared/DevToolsUtils.js new file mode 100644 index 0000000000..2a9979b456 --- /dev/null +++ b/devtools/shared/DevToolsUtils.js @@ -0,0 +1,1023 @@ +/* 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/. */ + +/* globals setImmediate, rpc */ + +"use strict"; + +/* General utilities used throughout devtools. */ + +var flags = require("resource://devtools/shared/flags.js"); +var { + getStack, + callFunctionWithAsyncStack, +} = require("resource://devtools/shared/platform/stack.js"); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + NetworkHelper: + "resource://devtools/shared/network-observer/NetworkHelper.sys.mjs", +}); + +ChromeUtils.defineModuleGetter( + lazy, + "ObjectUtils", + "resource://gre/modules/ObjectUtils.jsm" +); + +// Native getters which are considered to be side effect free. +ChromeUtils.defineLazyGetter(lazy, "sideEffectFreeGetters", () => { + const { + getters, + } = require("resource://devtools/server/actors/webconsole/eager-ecma-allowlist.js"); + + const map = new Map(); + for (const n of getters) { + if (!map.has(n.name)) { + map.set(n.name, []); + } + map.get(n.name).push(n); + } + + return map; +}); + +// Using this name lets the eslint plugin know about lazy defines in +// this file. +var DevToolsUtils = exports; + +// Re-export the thread-safe utils. +const ThreadSafeDevToolsUtils = require("resource://devtools/shared/ThreadSafeDevToolsUtils.js"); +for (const key of Object.keys(ThreadSafeDevToolsUtils)) { + exports[key] = ThreadSafeDevToolsUtils[key]; +} + +/** + * Waits for the next tick in the event loop to execute a callback. + */ +exports.executeSoon = function (fn) { + if (isWorker) { + setImmediate(fn); + } else { + let executor; + // Only enable async stack reporting when DEBUG_JS_MODULES is set + // (customized local builds) to avoid a performance penalty. + if (AppConstants.DEBUG_JS_MODULES || flags.testing) { + const stack = getStack(); + executor = () => { + callFunctionWithAsyncStack(fn, stack, "DevToolsUtils.executeSoon"); + }; + } else { + executor = fn; + } + Services.tm.dispatchToMainThread({ + run: exports.makeInfallible(executor), + }); + } +}; + +/** + * Similar to executeSoon, but enters microtask before executing the callback + * if this is called on the main thread. + */ +exports.executeSoonWithMicroTask = function (fn) { + if (isWorker) { + setImmediate(fn); + } else { + let executor; + // Only enable async stack reporting when DEBUG_JS_MODULES is set + // (customized local builds) to avoid a performance penalty. + if (AppConstants.DEBUG_JS_MODULES || flags.testing) { + const stack = getStack(); + executor = () => { + callFunctionWithAsyncStack( + fn, + stack, + "DevToolsUtils.executeSoonWithMicroTask" + ); + }; + } else { + executor = fn; + } + Services.tm.dispatchToMainThreadWithMicroTask({ + run: exports.makeInfallible(executor), + }); + } +}; + +/** + * Waits for the next tick in the event loop. + * + * @return Promise + * A promise that is resolved after the next tick in the event loop. + */ +exports.waitForTick = function () { + return new Promise(resolve => { + exports.executeSoon(resolve); + }); +}; + +/** + * Waits for the specified amount of time to pass. + * + * @param number delay + * The amount of time to wait, in milliseconds. + * @return Promise + * A promise that is resolved after the specified amount of time passes. + */ +exports.waitForTime = function (delay) { + return new Promise(resolve => setTimeout(resolve, delay)); +}; + +/** + * Like XPCOMUtils.defineLazyGetter, but with a |this| sensitive getter that + * allows the lazy getter to be defined on a prototype and work correctly with + * instances. + * + * @param Object object + * The prototype object to define the lazy getter on. + * @param String key + * The key to define the lazy getter on. + * @param Function callback + * The callback that will be called to determine the value. Will be + * called with the |this| value of the current instance. + */ +exports.defineLazyPrototypeGetter = function (object, key, callback) { + Object.defineProperty(object, key, { + configurable: true, + get() { + const value = callback.call(this); + + Object.defineProperty(this, key, { + configurable: true, + writable: true, + value, + }); + + return value; + }, + }); +}; + +/** + * Safely get the property value from a Debugger.Object for a given key. Walks + * the prototype chain until the property is found. + * + * @param {Debugger.Object} object + * The Debugger.Object to get the value from. + * @param {String} key + * The key to look for. + * @param {Boolean} invokeUnsafeGetter (defaults to false). + * Optional boolean to indicate if the function should execute unsafe getter + * in order to retrieve its result's properties. + * ⚠️ This should be set to true *ONLY* on user action as it may cause side-effects + * in the content page ⚠️ + * @return Any + */ +exports.getProperty = function (object, key, invokeUnsafeGetters = false) { + const root = object; + while (object && exports.isSafeDebuggerObject(object)) { + let desc; + try { + desc = object.getOwnPropertyDescriptor(key); + } catch (e) { + // The above can throw when the debuggee does not subsume the object's + // compartment, or for some WrappedNatives like Cu.Sandbox. + return undefined; + } + if (desc) { + if ("value" in desc) { + return desc.value; + } + // Call the getter if it's safe. + if (exports.hasSafeGetter(desc) || invokeUnsafeGetters === true) { + try { + return desc.get.call(root).return; + } catch (e) { + // If anything goes wrong report the error and return undefined. + exports.reportException("getProperty", e); + } + } + return undefined; + } + object = object.proto; + } + return undefined; +}; + +/** + * Removes all the non-opaque security wrappers of a debuggee object. + * + * @param obj Debugger.Object + * The debuggee object to be unwrapped. + * @return Debugger.Object|null|undefined + * - If the object has no wrapper, the same `obj` is returned. Note DeadObject + * objects belong to this case. + * - Otherwise, if the debuggee doesn't subsume object's compartment, returns `null`. + * - Otherwise, if the object belongs to an invisible-to-debugger compartment, + * returns `undefined`. + * - Otherwise, returns the unwrapped object. + */ +exports.unwrap = function unwrap(obj) { + // Check if `obj` has an opaque wrapper. + if (obj.class === "Opaque") { + return obj; + } + + // Attempt to unwrap via `obj.unwrap()`. Note that: + // - This will return `null` if the debuggee does not subsume object's compartment. + // - This will throw if the object belongs to an invisible-to-debugger compartment. + // - This will return `obj` if there is no wrapper. + let unwrapped; + try { + unwrapped = obj.unwrap(); + } catch (err) { + return undefined; + } + + // Check if further unwrapping is not possible. + if (!unwrapped || unwrapped === obj) { + return unwrapped; + } + + // Recursively remove additional security wrappers. + return unwrap(unwrapped); +}; + +/** + * Checks whether a debuggee object is safe. Unsafe objects may run proxy traps or throw + * when using `proto`, `isExtensible`, `isFrozen` or `isSealed`. Note that safe objects + * may still throw when calling `getOwnPropertyNames`, `getOwnPropertyDescriptor`, etc. + * Also note DeadObject objects are considered safe. + * + * @param obj Debugger.Object + * The debuggee object to be checked. + * @return boolean + */ +exports.isSafeDebuggerObject = function (obj) { + const unwrapped = exports.unwrap(obj); + + // Objects belonging to an invisible-to-debugger compartment might be proxies, + // so just in case consider them unsafe. + if (unwrapped === undefined) { + return false; + } + + // If the debuggee does not subsume the object's compartment, most properties won't + // be accessible. Cross-origin Window and Location objects might expose some, though. + // Therefore, it must be considered safe. Note that proxy objects have fully opaque + // security wrappers, so proxy traps won't run in this case. + if (unwrapped === null) { + return true; + } + + // Proxy objects can run traps when accessed. `isProxy` getter is called on `unwrapped` + // instead of on `obj` in order to detect proxies behind transparent wrappers. + if (unwrapped.isProxy) { + return false; + } + + return true; +}; + +/** + * Determines if a descriptor has a getter which doesn't call into JavaScript. + * + * @param Object desc + * The descriptor to check for a safe getter. + * @return Boolean + * Whether a safe getter was found. + */ +exports.hasSafeGetter = function (desc) { + // Scripted functions that are CCWs will not appear scripted until after + // unwrapping. + let fn = desc.get; + fn = fn && exports.unwrap(fn); + if (!fn) { + return false; + } + if (!fn.callable || fn.class !== "Function") { + return false; + } + if (fn.script !== undefined) { + // This is scripted function. + return false; + } + + // This is a getter with native function. + + // We assume all DOM getters have no major side effect, and they are + // eagerly-evaluateable. + // + // JitInfo is used only by methods/accessors in WebIDL, and being + // "a getter with JitInfo" can be used as a condition to check if given + // function is DOM getter. + // + // This includes privileged interfaces in addition to standard web APIs. + if (fn.isNativeGetterWithJitInfo()) { + return true; + } + + // Apply explicit allowlist. + const natives = lazy.sideEffectFreeGetters.get(fn.name); + return natives && natives.some(n => fn.isSameNative(n)); +}; + +/** + * Check that the property value from a Debugger.Object for a given key is an unsafe + * getter or not. Walks the prototype chain until the property is found. + * + * @param {Debugger.Object} object + * The Debugger.Object to check on. + * @param {String} key + * The key to look for. + * @param {Boolean} invokeUnsafeGetter (defaults to false). + * Optional boolean to indicate if the function should execute unsafe getter + * in order to retrieve its result's properties. + * @return Boolean + */ +exports.isUnsafeGetter = function (object, key) { + while (object && exports.isSafeDebuggerObject(object)) { + let desc; + try { + desc = object.getOwnPropertyDescriptor(key); + } catch (e) { + // The above can throw when the debuggee does not subsume the object's + // compartment, or for some WrappedNatives like Cu.Sandbox. + return false; + } + if (desc) { + if (Object.getOwnPropertyNames(desc).includes("get")) { + return !exports.hasSafeGetter(desc); + } + } + object = object.proto; + } + + return false; +}; + +/** + * Check if it is safe to read properties and execute methods from the given JS + * object. Safety is defined as being protected from unintended code execution + * from content scripts (or cross-compartment code). + * + * See bugs 945920 and 946752 for discussion. + * + * @type Object obj + * The object to check. + * @return Boolean + * True if it is safe to read properties from obj, or false otherwise. + */ +exports.isSafeJSObject = function (obj) { + // If we are running on a worker thread, Cu is not available. In this case, + // we always return false, just to be on the safe side. + if (isWorker) { + return false; + } + + if ( + Cu.getGlobalForObject(obj) == Cu.getGlobalForObject(exports.isSafeJSObject) + ) { + // obj is not a cross-compartment wrapper. + return true; + } + + // Xray wrappers protect against unintended code execution. + if (Cu.isXrayWrapper(obj)) { + return true; + } + + // If there aren't Xrays, only allow chrome objects. + const principal = Cu.getObjectPrincipal(obj); + if (!principal.isSystemPrincipal) { + return false; + } + + // Scripted proxy objects without Xrays can run their proxy traps. + if (Cu.isProxy(obj)) { + return false; + } + + // Even if `obj` looks safe, an unsafe object in its prototype chain may still + // run unintended code, e.g. when using the `instanceof` operator. + const proto = Object.getPrototypeOf(obj); + if (proto && !exports.isSafeJSObject(proto)) { + return false; + } + + // Allow non-problematic chrome objects. + return true; +}; + +/** + * Dump with newline - This is a logging function that will only output when + * the preference "devtools.debugger.log" is set to true. Typically it is used + * for logging the remote debugging protocol calls. + */ +exports.dumpn = function (str) { + if (flags.wantLogging) { + dump("DBG-SERVER: " + str + "\n"); + } +}; + +/** + * Dump verbose - This is a verbose logger for low-level tracing, that is typically + * used to provide information about the remote debugging protocol's transport + * mechanisms. The logging can be enabled by changing the preferences + * "devtools.debugger.log" and "devtools.debugger.log.verbose" to true. + */ +exports.dumpv = function (msg) { + if (flags.wantVerbose) { + exports.dumpn(msg); + } +}; + +/** + * Defines a getter on a specified object that will be created upon first use. + * + * @param object + * The object to define the lazy getter on. + * @param name + * The name of the getter to define on object. + * @param lambda + * A function that returns what the getter should return. This will + * only ever be called once. + */ +exports.defineLazyGetter = function (object, name, lambda) { + Object.defineProperty(object, name, { + get() { + delete object[name]; + object[name] = lambda.apply(object); + return object[name]; + }, + configurable: true, + enumerable: true, + }); +}; + +DevToolsUtils.defineLazyGetter(this, "AppConstants", () => { + if (isWorker) { + return {}; + } + return ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" + ).AppConstants; +}); + +/** + * No operation. The empty function. + */ +exports.noop = function () {}; + +let assertionFailureCount = 0; + +Object.defineProperty(exports, "assertionFailureCount", { + get() { + return assertionFailureCount; + }, +}); + +function reallyAssert(condition, message) { + if (!condition) { + assertionFailureCount++; + const err = new Error("Assertion failure: " + message); + exports.reportException("DevToolsUtils.assert", err); + throw err; + } +} + +/** + * DevToolsUtils.assert(condition, message) + * + * @param Boolean condition + * @param String message + * + * Assertions are enabled when any of the following are true: + * - This is a DEBUG_JS_MODULES build + * - flags.testing is set to true + * + * If assertions are enabled, then `condition` is checked and if false-y, the + * assertion failure is logged and then an error is thrown. + * + * If assertions are not enabled, then this function is a no-op. + */ +Object.defineProperty(exports, "assert", { + get: () => + AppConstants.DEBUG_JS_MODULES || flags.testing + ? reallyAssert + : exports.noop, +}); + +DevToolsUtils.defineLazyGetter(this, "NetUtil", () => { + return ChromeUtils.import("resource://gre/modules/NetUtil.jsm").NetUtil; +}); + +/** + * Performs a request to load the desired URL and returns a promise. + * + * @param urlIn String + * The URL we will request. + * @param aOptions Object + * An object with the following optional properties: + * - loadFromCache: if false, will bypass the cache and + * always load fresh from the network (default: true) + * - policy: the nsIContentPolicy type to apply when fetching the URL + * (only works when loading from system principal) + * - window: the window to get the loadGroup from + * - charset: the charset to use if the channel doesn't provide one + * - principal: the principal to use, if omitted, the request is loaded + * with a content principal corresponding to the url being + * loaded, using the origin attributes of the window, if any. + * - cacheKey: when loading from cache, use this key to retrieve a cache + * specific to a given SHEntry. (Allows loading POST + * requests from cache) + * @returns Promise that resolves with an object with the following members on + * success: + * - content: the document at that URL, as a string, + * - contentType: the content type of the document + * + * If an error occurs, the promise is rejected with that error. + * + * XXX: It may be better to use nsITraceableChannel to get to the sources + * without relying on caching when we can (not for eval, etc.): + * http://www.softwareishard.com/blog/firebug/nsitraceablechannel-intercept-http-traffic/ + */ +function mainThreadFetch( + urlIn, + aOptions = { + loadFromCache: true, + policy: Ci.nsIContentPolicy.TYPE_OTHER, + window: null, + charset: null, + principal: null, + cacheKey: 0, + } +) { + return new Promise((resolve, reject) => { + // Create a channel. + const url = urlIn.split(" -> ").pop(); + let channel; + try { + channel = newChannelForURL(url, aOptions); + } catch (ex) { + reject(ex); + return; + } + + channel.loadInfo.isInDevToolsContext = true; + + // Set the channel options. + channel.loadFlags = aOptions.loadFromCache + ? channel.LOAD_FROM_CACHE + : channel.LOAD_BYPASS_CACHE; + + if (aOptions.loadFromCache && channel instanceof Ci.nsICacheInfoChannel) { + // If DevTools intents to load the content from the cache, + // we make the LOAD_FROM_CACHE flag preferred over LOAD_BYPASS_CACHE. + channel.preferCacheLoadOverBypass = true; + + // When loading from cache, the cacheKey allows us to target a specific + // SHEntry and offer ways to restore POST requests from cache. + if (aOptions.cacheKey != 0) { + channel.cacheKey = aOptions.cacheKey; + } + } + + if (aOptions.window) { + // Respect private browsing. + channel.loadGroup = aOptions.window.docShell.QueryInterface( + Ci.nsIDocumentLoader + ).loadGroup; + } + + // eslint-disable-next-line complexity + const onResponse = (stream, status, request) => { + if (!Components.isSuccessCode(status)) { + reject(new Error(`Failed to fetch ${url}. Code ${status}.`)); + return; + } + + try { + // We cannot use NetUtil to do the charset conversion as if charset + // information is not available and our default guess is wrong the method + // might fail and we lose the stream data. This means we can't fall back + // to using the locale default encoding (bug 1181345). + + // Read and decode the data according to the locale default encoding. + + let available; + try { + available = stream.available(); + } catch (ex) { + if (ex.name === "NS_BASE_STREAM_CLOSED") { + // Empty files cause NS_BASE_STREAM_CLOSED exception. + // If there was a real stream error, we would have already rejected above. + resolve({ + content: "", + contentType: "text/plan", + }); + return; + } + + reject(ex); + } + let source = NetUtil.readInputStreamToString(stream, available); + stream.close(); + + // We do our own BOM sniffing here because there's no convenient + // implementation of the "decode" algorithm + // (https://encoding.spec.whatwg.org/#decode) exposed to JS. + let bomCharset = null; + if ( + available >= 3 && + source.codePointAt(0) == 0xef && + source.codePointAt(1) == 0xbb && + source.codePointAt(2) == 0xbf + ) { + bomCharset = "UTF-8"; + source = source.slice(3); + } else if ( + available >= 2 && + source.codePointAt(0) == 0xfe && + source.codePointAt(1) == 0xff + ) { + bomCharset = "UTF-16BE"; + source = source.slice(2); + } else if ( + available >= 2 && + source.codePointAt(0) == 0xff && + source.codePointAt(1) == 0xfe + ) { + bomCharset = "UTF-16LE"; + source = source.slice(2); + } + + // If the channel or the caller has correct charset information, the + // content will be decoded correctly. If we have to fall back to UTF-8 and + // the guess is wrong, the conversion fails and convertToUnicode returns + // the input unmodified. Essentially we try to decode the data as UTF-8 + // and if that fails, we use the locale specific default encoding. This is + // the best we can do if the source does not provide charset info. + let charset = bomCharset; + if (!charset) { + try { + charset = channel.contentCharset; + } catch (e) { + // Accessing `contentCharset` on content served by a service worker in + // non-e10s may throw. + } + } + if (!charset) { + charset = aOptions.charset || "UTF-8"; + } + const unicodeSource = lazy.NetworkHelper.convertToUnicode( + source, + charset + ); + + // Look for any source map URL in the response. + let sourceMapURL; + if (request instanceof Ci.nsIHttpChannel) { + try { + sourceMapURL = request.getResponseHeader("SourceMap"); + } catch (e) {} + if (!sourceMapURL) { + try { + sourceMapURL = request.getResponseHeader("X-SourceMap"); + } catch (e) {} + } + } + + resolve({ + content: unicodeSource, + contentType: request.contentType, + sourceMapURL, + }); + } catch (ex) { + reject(ex); + } + }; + + // Open the channel + try { + NetUtil.asyncFetch(channel, onResponse); + } catch (ex) { + reject(ex); + } + }); +} + +/** + * Opens a channel for given URL. Tries a bit harder than NetUtil.newChannel. + * + * @param {String} url - The URL to open a channel for. + * @param {Object} options - The options object passed to @method fetch. + * @return {nsIChannel} - The newly created channel. Throws on failure. + */ +function newChannelForURL( + url, + { policy, window, principal }, + recursing = false +) { + const securityFlags = + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL; + + let uri; + try { + uri = Services.io.newURI(url); + } catch (e) { + // In the xpcshell tests, the script url is the absolute path of the test + // file, which will make a malformed URI error be thrown. Add the file + // scheme to see if it helps. + uri = Services.io.newURI("file://" + url); + } + const channelOptions = { + contentPolicyType: policy, + securityFlags, + uri, + }; + + // Ensure that we have some contentPolicyType type set if one was + // not provided. + if (!channelOptions.contentPolicyType) { + channelOptions.contentPolicyType = Ci.nsIContentPolicy.TYPE_OTHER; + } + + // If a window is provided, always use it's document as the loadingNode. + // This will provide the correct principal, origin attributes, service + // worker controller, etc. + if (window) { + channelOptions.loadingNode = window.document; + } else { + // If a window is not provided, then we must set a loading principal. + + // If the caller did not provide a principal, then we use the URI + // to create one. Note, it's not clear what use cases require this + // and it may not be correct. + let prin = principal; + if (!prin) { + prin = Services.scriptSecurityManager.createContentPrincipal(uri, {}); + } + + channelOptions.loadingPrincipal = prin; + } + + try { + return NetUtil.newChannel(channelOptions); + } catch (e) { + // Don't infinitely recurse if newChannel keeps throwing. + if (recursing) { + throw e; + } + + // In xpcshell tests on Windows, nsExternalProtocolHandler::NewChannel() + // can throw NS_ERROR_UNKNOWN_PROTOCOL if the external protocol isn't + // supported by Windows, so we also need to handle the exception here if + // parsing the URL above doesn't throw. + return newChannelForURL( + "file://" + url, + { policy, window, principal }, + /* recursing */ true + ); + } +} + +// Fetch is defined differently depending on whether we are on the main thread +// or a worker thread. +if (this.isWorker) { + // Services is not available in worker threads, nor is there any other way + // to fetch a URL. We need to enlist the help from the main thread here, by + // issuing an rpc request, to fetch the URL on our behalf. + exports.fetch = function (url, options) { + return rpc("fetch", url, options); + }; +} else { + exports.fetch = mainThreadFetch; +} + +/** + * Open the file at the given path for reading. + * + * @param {String} filePath + * + * @returns Promise + */ +exports.openFileStream = function (filePath) { + return new Promise((resolve, reject) => { + const uri = NetUtil.newURI(new lazy.FileUtils.File(filePath)); + NetUtil.asyncFetch( + { uri, loadUsingSystemPrincipal: true }, + (stream, result) => { + if (!Components.isSuccessCode(result)) { + reject(new Error(`Could not open "${filePath}": result = ${result}`)); + return; + } + + resolve(stream); + } + ); + }); +}; + +/** + * Save the given data to disk after asking the user where to do so. + * + * @param {Window} parentWindow + * The parent window to use to display the filepicker. + * @param {UInt8Array} dataArray + * The data to write to the file. + * @param {String} fileName + * The suggested filename. + * @param {Array} filters + * An array of object of the following shape: + * - pattern: A pattern for accepted files (example: "*.js") + * - label: The label that will be displayed in the save file dialog. + * @return {String|null} + * The path to the local saved file, if saved. + */ +exports.saveAs = async function ( + parentWindow, + dataArray, + fileName = "", + filters = [] +) { + let returnFile; + try { + returnFile = await exports.showSaveFileDialog( + parentWindow, + fileName, + filters + ); + } catch (ex) { + return null; + } + + await IOUtils.write(returnFile.path, dataArray, { + tmpPath: returnFile.path + ".tmp", + }); + + return returnFile.path; +}; + +/** + * Show file picker and return the file user selected. + * + * @param {nsIWindow} parentWindow + * Optional parent window. If null the parent window of the file picker + * will be the window of the attached input element. + * @param {String} suggestedFilename + * The suggested filename. + * @param {Array} filters + * An array of object of the following shape: + * - pattern: A pattern for accepted files (example: "*.js") + * - label: The label that will be displayed in the save file dialog. + * @return {Promise} + * A promise that is resolved after the file is selected by the file picker + */ +exports.showSaveFileDialog = function ( + parentWindow, + suggestedFilename, + filters = [] +) { + const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + + if (suggestedFilename) { + fp.defaultString = suggestedFilename; + } + + fp.init(parentWindow, null, fp.modeSave); + if (Array.isArray(filters) && filters.length) { + for (const { pattern, label } of filters) { + fp.appendFilter(label, pattern); + } + } else { + fp.appendFilters(fp.filterAll); + } + + return new Promise((resolve, reject) => { + fp.open(result => { + if (result == Ci.nsIFilePicker.returnCancel) { + reject(); + } else { + resolve(fp.file); + } + }); + }); +}; + +/* + * All of the flags have been moved to a different module. Make sure + * nobody is accessing them anymore, and don't write new code using + * them. We can remove this code after a while. + */ +function errorOnFlag(exports, name) { + Object.defineProperty(exports, name, { + get: () => { + const msg = + `Cannot get the flag ${name}. ` + + `Use the "devtools/shared/flags" module instead`; + console.error(msg); + throw new Error(msg); + }, + set: () => { + const msg = + `Cannot set the flag ${name}. ` + + `Use the "devtools/shared/flags" module instead`; + console.error(msg); + throw new Error(msg); + }, + }); +} + +errorOnFlag(exports, "testing"); +errorOnFlag(exports, "wantLogging"); +errorOnFlag(exports, "wantVerbose"); + +// Calls the property with the given `name` on the given `object`, where +// `name` is a string, and `object` a Debugger.Object instance. +// +// This function uses only the Debugger.Object API to call the property. It +// avoids the use of unsafeDeference. This is useful for example in workers, +// where unsafeDereference will return an opaque security wrapper to the +// referent. +function callPropertyOnObject(object, name, ...args) { + // Find the property. + let descriptor; + let proto = object; + do { + descriptor = proto.getOwnPropertyDescriptor(name); + if (descriptor !== undefined) { + break; + } + proto = proto.proto; + } while (proto !== null); + if (descriptor === undefined) { + throw new Error("No such property"); + } + const value = descriptor.value; + if (typeof value !== "object" || value === null || !("callable" in value)) { + throw new Error("Not a callable object."); + } + + // Call the property. + const result = value.call(object, ...args); + if (result === null) { + throw new Error("Code was terminated."); + } + if ("throw" in result) { + throw result.throw; + } + return result.return; +} + +exports.callPropertyOnObject = callPropertyOnObject; + +// Convert a Debugger.Object wrapping an iterator into an iterator in the +// debugger's realm. +function* makeDebuggeeIterator(object) { + while (true) { + const nextValue = callPropertyOnObject(object, "next"); + if (exports.getProperty(nextValue, "done")) { + break; + } + yield exports.getProperty(nextValue, "value"); + } +} + +exports.makeDebuggeeIterator = makeDebuggeeIterator; + +/** + * Shared helper to retrieve the topmost window. This can be used to retrieve the chrome + * window embedding the DevTools frame. + */ +function getTopWindow(win) { + return win.windowRoot ? win.windowRoot.ownerGlobal : win.top; +} + +exports.getTopWindow = getTopWindow; + +/** + * Check whether two objects are identical by performing + * a deep equality check on their properties and values. + * See toolkit/modules/ObjectUtils.jsm for implementation. + * + * @param {Object} a + * @param {Object} b + * @return {Boolean} + */ +exports.deepEqual = (a, b) => { + return lazy.ObjectUtils.deepEqual(a, b); +}; + +function isWorkerDebuggerAlive(dbg) { + // Some workers are zombies. `isClosed` is false, but nothing works. + // `postMessage` is a noop, `addListener`'s `onClosed` doesn't work. + // (Ignore dbg without `window` as they aren't related to docShell + // and probably do not suffer form this issue) + return !dbg.isClosed && (!dbg.window || dbg.window.docShell); +} +exports.isWorkerDebuggerAlive = isWorkerDebuggerAlive; diff --git a/devtools/shared/ThreadSafeDevToolsUtils.js b/devtools/shared/ThreadSafeDevToolsUtils.js new file mode 100644 index 0000000000..fba93a9dcc --- /dev/null +++ b/devtools/shared/ThreadSafeDevToolsUtils.js @@ -0,0 +1,363 @@ +/* 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"; + +/** + * General utilities used throughout devtools that can also be used in + * workers. + */ + +/** + * Immutably reduce the given `...objs` into one object. The reduction is + * applied from left to right, so `immutableUpdate({ a: 1 }, { a: 2 })` will + * result in `{ a: 2 }`. The resulting object is frozen. + * + * Example usage: + * + * const original = { foo: 1, bar: 2, baz: 3 }; + * const modified = immutableUpdate(original, { baz: 0, bang: 4 }); + * + * // We get the new object that we expect... + * assert(modified.baz === 0); + * assert(modified.bang === 4); + * + * // However, the original is not modified. + * assert(original.baz === 2); + * assert(original.bang === undefined); + * + * @param {...Object} ...objs + * @returns {Object} + */ +exports.immutableUpdate = function (...objs) { + return Object.freeze(Object.assign({}, ...objs)); +}; + +/** + * Utility function for updating an object with the properties of + * other objects. + * + * DEPRECATED: Just use Object.assign() instead! + * + * @param aTarget Object + * The object being updated. + * @param aNewAttrs Object + * The rest params are objects to update aTarget with. You + * can pass as many as you like. + */ +exports.update = function update(target, ...args) { + for (const attrs of args) { + for (const key in attrs) { + const desc = Object.getOwnPropertyDescriptor(attrs, key); + + if (desc) { + Object.defineProperty(target, key, desc); + } + } + } + return target; +}; + +/** + * Utility function for getting the values from an object as an array + * + * @param object Object + * The object to iterate over + */ +exports.values = function values(object) { + return Object.keys(object).map(k => object[k]); +}; + +/** + * Report that |who| threw an exception, |exception|. + */ +exports.reportException = function reportException(who, exception) { + const msg = `${who} threw an exception: ${exports.safeErrorString( + exception + )}`; + dump(msg + "\n"); + + if (typeof console !== "undefined" && console && console.error) { + console.error(exception); + } +}; + +/** + * Given a handler function that may throw, return an infallible handler + * function that calls the fallible handler, and logs any exceptions it + * throws. + * + * @param handler function + * A handler function, which may throw. + * @param aName string + * A name for handler, for use in error messages. If omitted, we use + * handler.name. + * + * (SpiderMonkey does generate good names for anonymous functions, but we + * don't have a way to get at them from JavaScript at the moment.) + */ +exports.makeInfallible = function (handler, name = handler.name) { + return function () { + try { + return handler.apply(this, arguments); + } catch (ex) { + let who = "Handler function"; + if (name) { + who += " " + name; + } + exports.reportException(who, ex); + return undefined; + } + }; +}; + +/** + * Turn the |error| into a string, without fail. + * + * @param {Error|any} error + */ +exports.safeErrorString = function (error) { + try { + let errorString = error.toString(); + if (typeof errorString == "string") { + // Attempt to attach a stack to |errorString|. If it throws an error, or + // isn't a string, don't use it. + try { + if (error.stack) { + const stack = error.stack.toString(); + if (typeof stack == "string") { + errorString += "\nStack: " + stack; + } + } + } catch (ee) { + // Ignore. + } + + // Append additional line and column number information to the output, + // since it might not be part of the stringified error. + if ( + typeof error.lineNumber == "number" && + typeof error.columnNumber == "number" + ) { + errorString += + "Line: " + error.lineNumber + ", column: " + error.columnNumber; + } + + return errorString; + } + } catch (ee) { + // Ignore. + } + + // We failed to find a good error description, so do the next best thing. + return Object.prototype.toString.call(error); +}; + +/** + * Interleaves two arrays element by element, returning the combined array, like + * a zip. In the case of arrays with different sizes, undefined values will be + * interleaved at the end along with the extra values of the larger array. + * + * @param Array a + * @param Array b + * @returns Array + * The combined array, in the form [a1, b1, a2, b2, ...] + */ +exports.zip = function (a, b) { + if (!b) { + return a; + } + if (!a) { + return b; + } + const pairs = []; + for ( + let i = 0, aLength = a.length, bLength = b.length; + i < aLength || i < bLength; + i++ + ) { + pairs.push([a[i], b[i]]); + } + return pairs; +}; + +/** + * Converts an object into an array with 2-element arrays as key/value + * pairs of the object. `{ foo: 1, bar: 2}` would become + * `[[foo, 1], [bar 2]]` (order not guaranteed). + * + * @param object obj + * @returns array + */ +exports.entries = function entries(obj) { + return Object.keys(obj).map(k => [k, obj[k]]); +}; + +/* + * Takes an array of 2-element arrays as key/values pairs and + * constructs an object using them. + */ +exports.toObject = function (arr) { + const obj = {}; + for (const [k, v] of arr) { + obj[k] = v; + } + return obj; +}; + +/** + * Composes the given functions into a single function, which will + * apply the results of each function right-to-left, starting with + * applying the given arguments to the right-most function. + * `compose(foo, bar, baz)` === `args => foo(bar(baz(args)))` + * + * @param ...function funcs + * @returns function + */ +exports.compose = function compose(...funcs) { + return (...args) => { + const initialValue = funcs[funcs.length - 1](...args); + const leftFuncs = funcs.slice(0, -1); + return leftFuncs.reduceRight((composed, f) => f(composed), initialValue); + }; +}; + +/** + * Return true if `thing` is a generator function, false otherwise. + */ +exports.isGenerator = function (fn) { + if (typeof fn !== "function") { + return false; + } + const proto = Object.getPrototypeOf(fn); + if (!proto) { + return false; + } + const ctor = proto.constructor; + if (!ctor) { + return false; + } + return ctor.name == "GeneratorFunction"; +}; + +/** + * Return true if `thing` is an async function, false otherwise. + */ +exports.isAsyncFunction = function (fn) { + if (typeof fn !== "function") { + return false; + } + const proto = Object.getPrototypeOf(fn); + if (!proto) { + return false; + } + const ctor = proto.constructor; + if (!ctor) { + return false; + } + return ctor.name == "AsyncFunction"; +}; + +/** + * Return true if `thing` is a Promise or then-able, false otherwise. + */ +exports.isPromise = function (p) { + return p && typeof p.then === "function"; +}; + +/** + * Return true if `thing` is a SavedFrame, false otherwise. + */ +exports.isSavedFrame = function (thing) { + return Object.prototype.toString.call(thing) === "[object SavedFrame]"; +}; + +/** + * Return true iff `thing` is a `Set` object (possibly from another global). + */ +exports.isSet = function (thing) { + return Object.prototype.toString.call(thing) === "[object Set]"; +}; + +/** + * Given a list of lists, flatten it. Only flattens one level; does not + * recursively flatten all levels. + * + * @param {Array>} lists + * @return {Array} + */ +exports.flatten = function (lists) { + return Array.prototype.concat.apply([], lists); +}; + +/** + * Returns a promise that is resolved or rejected when all promises have settled + * (resolved or rejected). + * + * This differs from Promise.all, which will reject immediately after the first + * rejection, instead of waiting for the remaining promises to settle. + * + * @param values + * Iterable of promises that may be pending, resolved, or rejected. When + * when all promises have settled (resolved or rejected), the returned + * promise will be resolved or rejected as well. + * + * @return A new promise that is fulfilled when all values have settled + * (resolved or rejected). Its resolution value will be an array of all + * resolved values in the given order, or undefined if values is an + * empty array. The reject reason will be forwarded from the first + * promise in the list of given promises to be rejected. + */ +exports.settleAll = values => { + if (values === null || typeof values[Symbol.iterator] != "function") { + throw new Error("settleAll() expects an iterable."); + } + + return new Promise((resolve, reject) => { + values = Array.isArray(values) ? values : [...values]; + let countdown = values.length; + const resolutionValues = new Array(countdown); + let rejectionValue; + let rejectionOccurred = false; + + if (!countdown) { + resolve(resolutionValues); + return; + } + + function checkForCompletion() { + if (--countdown > 0) { + return; + } + if (!rejectionOccurred) { + resolve(resolutionValues); + } else { + reject(rejectionValue); + } + } + + for (let i = 0; i < values.length; i++) { + const index = i; + const value = values[i]; + const resolver = result => { + resolutionValues[index] = result; + checkForCompletion(); + }; + const rejecter = error => { + if (!rejectionOccurred) { + rejectionValue = error; + rejectionOccurred = true; + } + checkForCompletion(); + }; + + if (value && typeof value.then == "function") { + value.then(resolver, rejecter); + } else { + // Given value is not a promise, forward it as a resolution value. + resolver(value); + } + } + }); +}; diff --git a/devtools/shared/accessibility.js b/devtools/shared/accessibility.js new file mode 100644 index 0000000000..db6983d033 --- /dev/null +++ b/devtools/shared/accessibility.js @@ -0,0 +1,192 @@ +/* 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, + "colorUtils", + "resource://devtools/shared/css/color.js", + true +); +const { + accessibility: { + SCORES: { FAIL, AA, AAA }, + }, +} = require("resource://devtools/shared/constants.js"); + +/** + * Mapping of text size to contrast ratio score levels + */ +const LEVELS = { + LARGE_TEXT: { AA: 3, AAA: 4.5 }, + REGULAR_TEXT: { AA: 4.5, AAA: 7 }, +}; + +/** + * Mapping of large text size to CSS pixel value + */ +const LARGE_TEXT = { + // CSS pixel value (constant) that corresponds to 14 point text size which defines large + // text when font text is bold (font weight is greater than or equal to 600). + BOLD_LARGE_TEXT_MIN_PIXELS: 18.66, + // CSS pixel value (constant) that corresponds to 18 point text size which defines large + // text for normal text (e.g. not bold). + LARGE_TEXT_MIN_PIXELS: 24, +}; + +/** + * Get contrast ratio score based on WCAG criteria. + * @param {Number} ratio + * Value of the contrast ratio for a given accessible object. + * @param {Boolean} isLargeText + * True if the accessible object contains large text. + * @return {String} + * Value that represents calculated contrast ratio score. + */ +function getContrastRatioScore(ratio, isLargeText) { + const levels = isLargeText ? LEVELS.LARGE_TEXT : LEVELS.REGULAR_TEXT; + + let score = FAIL; + if (ratio >= levels.AAA) { + score = AAA; + } else if (ratio >= levels.AA) { + score = AA; + } + + return score; +} + +/** + * Get calculated text style properties from a node's computed style, if possible. + * @param {Object} computedStyle + * Computed style using which text styling information is to be calculated. + * - fontSize {String} + * Font size of the text + * - fontWeight {String} + * Font weight of the text + * - color {String} + * Rgb color of the text + * - opacity {String} Optional + * Opacity of the text + * @return {Object} + * Color and text size information for a given DOM node. + */ +function getTextProperties(computedStyle) { + const { color, fontSize, fontWeight } = computedStyle; + let { r, g, b, a } = InspectorUtils.colorToRGBA(color); + + // If the element has opacity in addition to background alpha value, take it + // into account. TODO: this does not handle opacity set on ancestor elements + // (see bug https://bugzilla.mozilla.org/show_bug.cgi?id=1544721). + const opacity = computedStyle.opacity + ? parseFloat(computedStyle.opacity) + : null; + if (opacity) { + a = opacity * a; + } + + const textRgbaColor = new colorUtils.CssColor( + `rgba(${r}, ${g}, ${b}, ${a})`, + true + ); + // TODO: For cases where text color is transparent, it likely comes from the color of + // the background that is underneath it (commonly from background-clip: text + // property). With some additional investigation it might be possible to calculate the + // color contrast where the color of the background is used as text color and the + // color of the ancestor's background is used as its background. + if (textRgbaColor.isTransparent()) { + return null; + } + + const isBoldText = parseInt(fontWeight, 10) >= 600; + const size = parseFloat(fontSize); + const isLargeText = + size >= + (isBoldText + ? LARGE_TEXT.BOLD_LARGE_TEXT_MIN_PIXELS + : LARGE_TEXT.LARGE_TEXT_MIN_PIXELS); + + return { + color: [r, g, b, a], + isLargeText, + isBoldText, + size, + opacity, + }; +} + +/** + * Calculates contrast ratio or range of contrast ratios of the referenced DOM node + * against the given background color data. If background is multi-colored, return a + * range, otherwise a single contrast ratio. + * + * @param {Object} backgroundColorData + * Object with one or more of the following properties: + * - value {Array} + * rgba array for single color background + * - min {Array} + * min luminance rgba array for multi color background + * - max {Array} + * max luminance rgba array for multi color background + * @param {Object} textData + * - color {Array} + * rgba array for text of referenced DOM node + * - isLargeText {Boolean} + * True if text of referenced DOM node is large + * @return {Object} + * An object that may contain one or more of the following fields: error, + * isLargeText, value, min, max values for contrast. + */ +function getContrastRatioAgainstBackground( + backgroundColorData, + { color, isLargeText } +) { + if (backgroundColorData.value) { + const value = colorUtils.calculateContrastRatio( + backgroundColorData.value, + color + ); + return { + value, + color, + backgroundColor: backgroundColorData.value, + isLargeText, + score: getContrastRatioScore(value, isLargeText), + }; + } + + let { min: backgroundColorMin, max: backgroundColorMax } = + backgroundColorData; + let min = colorUtils.calculateContrastRatio(backgroundColorMin, color); + let max = colorUtils.calculateContrastRatio(backgroundColorMax, color); + + // Flip minimum and maximum contrast ratios if necessary. + if (min > max) { + [min, max] = [max, min]; + [backgroundColorMin, backgroundColorMax] = [ + backgroundColorMax, + backgroundColorMin, + ]; + } + + const score = getContrastRatioScore(min, isLargeText); + + return { + min, + max, + color, + backgroundColorMin, + backgroundColorMax, + isLargeText, + score, + scoreMin: score, + scoreMax: getContrastRatioScore(max, isLargeText), + }; +} + +exports.getContrastRatioScore = getContrastRatioScore; +exports.getTextProperties = getTextProperties; +exports.getContrastRatioAgainstBackground = getContrastRatioAgainstBackground; +exports.LARGE_TEXT = LARGE_TEXT; diff --git a/devtools/shared/async-storage.js b/devtools/shared/async-storage.js new file mode 100644 index 0000000000..dd7ee0674e --- /dev/null +++ b/devtools/shared/async-storage.js @@ -0,0 +1,226 @@ +/* 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/. */ + +/** + * + * Adapted from https://github.com/mozilla-b2g/gaia/blob/f09993563fb5fec4393eb71816ce76cb00463190/shared/js/async_storage.js + * (converted to use Promises instead of callbacks). + * + * This file defines an asynchronous version of the localStorage API, backed by + * an IndexedDB database. It creates a global asyncStorage object that has + * methods like the localStorage object. + * + * To store a value use setItem: + * + * asyncStorage.setItem("key", "value"); + * + * This returns a promise in case you want confirmation that the value has been stored. + * + * asyncStorage.setItem("key", "newvalue").then(function() { + * console.log("new value stored"); + * }); + * + * To read a value, call getItem(), but note that you must wait for a promise + * resolution for the value to be retrieved. + * + * asyncStorage.getItem("key").then(function(value) { + * console.log("The value of key is:", value); + * }); + * + * Note that unlike localStorage, asyncStorage does not allow you to store and + * retrieve values by setting and querying properties directly. You cannot just + * write asyncStorage.key; you have to explicitly call setItem() or getItem(). + * + * removeItem(), clear(), length(), and key() are like the same-named methods of + * localStorage, and all return a promise. + * + * The asynchronous nature of getItem() makes it tricky to retrieve multiple + * values. But unlike localStorage, asyncStorage does not require the values you + * store to be strings. So if you need to save multiple values and want to + * retrieve them together, in a single asynchronous operation, just group the + * values into a single object. The properties of this object may not include + * DOM elements, but they may include things like Blobs and typed arrays. + * + */ + +"use strict"; + +const DBNAME = "devtools-async-storage"; +const DBVERSION = 1; +const STORENAME = "keyvaluepairs"; +var db = null; + +loader.lazyRequireGetter( + this, + "indexedDB", + "resource://devtools/shared/indexed-db.js" +); + +function withStore(type, onsuccess, onerror) { + if (db) { + const transaction = db.transaction(STORENAME, type); + const store = transaction.objectStore(STORENAME); + onsuccess(store); + } else { + const openreq = indexedDB.open(DBNAME, DBVERSION); + openreq.onerror = function withStoreOnError() { + onerror(); + }; + openreq.onupgradeneeded = function withStoreOnUpgradeNeeded() { + // First time setup: create an empty object store + openreq.result.createObjectStore(STORENAME); + }; + openreq.onsuccess = function withStoreOnSuccess() { + db = openreq.result; + const transaction = db.transaction(STORENAME, type); + const store = transaction.objectStore(STORENAME); + onsuccess(store); + }; + } +} + +function getItem(itemKey) { + return new Promise((resolve, reject) => { + let req; + withStore( + "readonly", + store => { + store.transaction.oncomplete = function onComplete() { + let value = req.result; + if (value === undefined) { + value = null; + } + resolve(value); + }; + req = store.get(itemKey); + req.onerror = function getItemOnError() { + console.error("Error in asyncStorage.getItem():", req.error.name); + reject(req.error); + }; + }, + reject + ); + }); +} + +function setItem(itemKey, value) { + return new Promise((resolve, reject) => { + withStore( + "readwrite", + store => { + store.transaction.oncomplete = resolve; + const req = store.put(value, itemKey); + req.onerror = function setItemOnError() { + console.error("Error in asyncStorage.setItem():", req.error.name); + reject(req.error); + }; + }, + reject + ); + }); +} + +function removeItem(itemKey) { + return new Promise((resolve, reject) => { + withStore( + "readwrite", + store => { + store.transaction.oncomplete = resolve; + const req = store.delete(itemKey); + req.onerror = function removeItemOnError() { + console.error("Error in asyncStorage.removeItem():", req.error.name); + reject(req.error); + }; + }, + reject + ); + }); +} + +function clear() { + return new Promise((resolve, reject) => { + withStore( + "readwrite", + store => { + store.transaction.oncomplete = resolve; + const req = store.clear(); + req.onerror = function clearOnError() { + console.error("Error in asyncStorage.clear():", req.error.name); + reject(req.error); + }; + }, + reject + ); + }); +} + +function length() { + return new Promise((resolve, reject) => { + let req; + withStore( + "readonly", + store => { + store.transaction.oncomplete = function onComplete() { + resolve(req.result); + }; + req = store.count(); + req.onerror = function lengthOnError() { + console.error("Error in asyncStorage.length():", req.error.name); + reject(req.error.name); + }; + }, + reject + ); + }); +} + +function key(n) { + return new Promise((resolve, reject) => { + if (n < 0) { + resolve(null); + return; + } + + let req; + withStore( + "readonly", + store => { + store.transaction.oncomplete = function onComplete() { + const cursor = req.result; + resolve(cursor ? cursor.key : null); + }; + let advanced = false; + req = store.openCursor(); + req.onsuccess = function keyOnSuccess() { + const cursor = req.result; + if (!cursor) { + // this means there weren"t enough keys + return; + } + if (n === 0 || advanced) { + // Either 1) we have the first key, return it if that's what they + // wanted, or 2) we"ve got the nth key. + return; + } + + // Otherwise, ask the cursor to skip ahead n records + advanced = true; + cursor.advance(n); + }; + req.onerror = function keyOnError() { + console.error("Error in asyncStorage.key():", req.error.name); + reject(req.error); + }; + }, + reject + ); + }); +} + +exports.getItem = getItem; +exports.setItem = setItem; +exports.removeItem = removeItem; +exports.clear = clear; +exports.length = length; +exports.key = key; diff --git a/devtools/shared/async-utils.js b/devtools/shared/async-utils.js new file mode 100644 index 0000000000..cb1fee1a4f --- /dev/null +++ b/devtools/shared/async-utils.js @@ -0,0 +1,71 @@ +/* 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"; + +/** + * Helpers for async functions. An async function returns a Promise for the + * resolution of the function. When the function returns, the promise is + * resolved with the returned value. If it throws the promise rejects with + * the thrown error. + */ + +/** + * Adds an event listener to the given element, and then removes its event + * listener once the event is called, returning the event object as a promise. + * @param Element element + * The DOM element to listen on + * @param String event + * The name of the event type to listen for + * @param Boolean useCapture + * Should we initiate the capture phase? + * @return Promise + * The promise resolved with the event object when the event first + * happens + */ +exports.listenOnce = function listenOnce(element, event, useCapture) { + return new Promise(function (resolve, reject) { + const onEvent = function (ev) { + element.removeEventListener(event, onEvent, useCapture); + resolve(ev); + }; + element.addEventListener(event, onEvent, useCapture); + }); +}; + +// Return value when `safeAsyncMethod` catches an error. +const SWALLOWED_RET = Symbol("swallowed"); + +/** + * Wraps the provided async method in a try/catch block. + * If an error is caught while running the method, check the provided condition + * to decide whether the error should bubble or not. + * + * @param {Function} asyncFn + * The async method to wrap. + * @param {Function} shouldSwallow + * Function that will run when an error is caught. If it returns true, + * the error will be swallowed. Otherwise, it will bubble up. + * @param {Mixed} retValue + * Optional value to return when an error is caught and is swallowed. + * @return {Function} The wrapped method. + */ +exports.safeAsyncMethod = function ( + asyncFn, + shouldSwallow, + retValue = SWALLOWED_RET +) { + return async function (...args) { + try { + const ret = await asyncFn(...args); + return ret; + } catch (e) { + if (shouldSwallow()) { + console.warn("Async method failed in safeAsyncMethod", e); + return retValue; + } + throw e; + } + }; +}; diff --git a/devtools/shared/commands/README.md b/devtools/shared/commands/README.md new file mode 100644 index 0000000000..3b05c765ab --- /dev/null +++ b/devtools/shared/commands/README.md @@ -0,0 +1,44 @@ +# 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. 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/index.js b/devtools/shared/commands/index.js new file mode 100644 index 0000000000..94cf7717cb --- /dev/null +++ b/devtools/shared/commands/index.js @@ -0,0 +1,133 @@ +/* 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"; + +// List of all command modules +// (please try to keep the list alphabetically sorted) +/* eslint sort-keys: "error" */ +/* eslint-enable sort-keys */ +const Commands = { + inspectedWindowCommand: + "devtools/shared/commands/inspected-window/inspected-window-command", + inspectorCommand: "devtools/shared/commands/inspector/inspector-command", + networkCommand: "devtools/shared/commands/network/network-command", + resourceCommand: "devtools/shared/commands/resource/resource-command", + rootResourceCommand: + "devtools/shared/commands/root-resource/root-resource-command", + scriptCommand: "devtools/shared/commands/script/script-command", + targetCommand: "devtools/shared/commands/target/target-command", + targetConfigurationCommand: + "devtools/shared/commands/target-configuration/target-configuration-command", + threadConfigurationCommand: + "devtools/shared/commands/thread-configuration/thread-configuration-command", +}; +/* eslint-disable sort-keys */ + +/** + * For a given descriptor and its related Targets, already initialized, + * return the dictionary with all command instances. + * This dictionary is lazy and commands will be loaded and instanciated on-demand. + */ +async function createCommandsDictionary(descriptorFront) { + // Bug 1675763: Watcher actor is not available in all situations yet. + let watcherFront; + const supportsWatcher = descriptorFront.traits?.watcher; + if (supportsWatcher) { + watcherFront = await descriptorFront.getWatcher(); + } + const { client } = descriptorFront; + + const allInstantiatedCommands = new Set(); + + const dictionary = { + // Expose both client and descriptor for legacy codebases, or tests. + // But ideally only commands should interact with these two objects + client, + descriptorFront, + watcherFront, + + // Expose for tests + waitForRequestsToSettle() { + return descriptorFront.client.waitForRequestsToSettle(); + }, + + // Boolean flag to know if the DevtoolsClient should be closed + // when this commands happens to be destroyed. + // This is set by: + // * commands-from-url in case we are opening a toolbox + // with a dedicated DevToolsClient (mostly from about:debugging, when the client isn't "cached"). + // * CommandsFactory, when we are connecting to a local tab and expect + // the client, toolbox and descriptor to all follow the same lifecycle. + shouldCloseClient: true, + + /** + * Destroy the commands which will destroy: + * - all inner commands, + * - the related descriptor, + * - the related DevToolsClient (not always) + */ + async destroy() { + descriptorFront.off("descriptor-destroyed", this.destroy); + + // Destroy all inner command modules + for (const command of allInstantiatedCommands) { + if (typeof command.destroy == "function") { + command.destroy(); + } + } + allInstantiatedCommands.clear(); + + // Destroy the descriptor front, and all its children fronts. + // Watcher, targets,... + // + // Note that DescriptorFront.destroy will be null because of Pool.destroy + // when this function is called while the descriptor front itself is being + // destroyed. + if (!descriptorFront.isDestroyed()) { + await descriptorFront.destroy(); + } + + // Close the DevToolsClient. Shutting down the connection + // to the debuggable context and its DevToolsServer. + // + // See shouldCloseClient jsdoc about this condition. + if (this.shouldCloseClient) { + await client.close(); + } + }, + }; + dictionary.destroy = dictionary.destroy.bind(dictionary); + + // Automatically destroy the commands object if the descriptor + // happens to be destroyed. Which means that the debuggable context + // is no longer debuggable. + descriptorFront.on("descriptor-destroyed", dictionary.destroy); + + for (const name in Commands) { + loader.lazyGetter(dictionary, name, () => { + 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..2cf831d7b0 --- /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.ini"] diff --git a/devtools/shared/commands/inspected-window/tests/browser.ini b/devtools/shared/commands/inspected-window/tests/browser.ini new file mode 100644 index 0000000000..d3b5ce5fbc --- /dev/null +++ b/devtools/shared/commands/inspected-window/tests/browser.ini @@ -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] +skip-if = http3 \ No newline at end of file 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..2ec17a9222 --- /dev/null +++ b/devtools/shared/commands/inspected-window/tests/inspectedwindow-reload-target.sjs @@ -0,0 +1,89 @@ +"use strict"; + +Cu.importGlobalProperties(["URLSearchParams"]); + +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..bcd8a14810 --- /dev/null +++ b/devtools/shared/commands/moz.build @@ -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 http://mozilla.org/MPL/2.0/. + +DIRS += [ + "inspected-window", + "inspector", + "network", + "resource", + "root-resource", + "script", + "target", + "target-configuration", + "thread-configuration", +] + +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..e765e5ac76 --- /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.ini"] 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.ini b/devtools/shared/commands/network/tests/browser.ini new file mode 100644 index 0000000000..ce1860f0f0 --- /dev/null +++ b/devtools/shared/commands/network/tests/browser.ini @@ -0,0 +1,11 @@ +[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/resource/legacy-listeners/console-messages.js b/devtools/shared/commands/resource/legacy-listeners/console-messages.js new file mode 100644 index 0000000000..ae3f81b4df --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/console-messages.js @@ -0,0 +1,59 @@ +/* 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 ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + // Allow the top level target unconditionnally. + // Also allow frame, but only in content toolbox, i.e. still ignore them in + // the context of the browser toolbox as we inspect messages via the process + // targets + const listenForFrames = targetCommand.descriptorFront.isTabDescriptor; + + // Allow workers when messages aren't dispatched to the main thread. + const listenForWorkers = + !targetCommand.rootFront.traits + .workerConsoleApiMessagesDispatchedToMainThread; + + const acceptTarget = + targetFront.isTopLevel || + targetFront.targetType === targetCommand.TYPES.PROCESS || + (targetFront.targetType === targetCommand.TYPES.FRAME && listenForFrames) || + (targetFront.targetType === targetCommand.TYPES.WORKER && listenForWorkers); + + if (!acceptTarget) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new messages + await webConsoleFront.startListeners(["ConsoleAPI"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners(ConsoleAPI) first /!\ + const { messages } = await webConsoleFront.getCachedMessages(["ConsoleAPI"]); + + for (const message of messages) { + message.resourceType = ResourceCommand.TYPES.CONSOLE_MESSAGE; + } + onAvailable(messages); + + // Forward new message events + webConsoleFront.on("consoleAPICall", message => { + // Ignore console messages that are cloned from the content process + // (they aren't relevant to toolboxes still using legacy listeners) + if (message.clonedFromContentProcess) { + return; + } + + message.resourceType = ResourceCommand.TYPES.CONSOLE_MESSAGE; + onAvailable([message]); + }); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/css-changes.js b/devtools/shared/commands/resource/legacy-listeners/css-changes.js new file mode 100644 index 0000000000..e9f3e17075 --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/css-changes.js @@ -0,0 +1,28 @@ +/* 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 ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetFront, onAvailable }) { + if (!targetFront.hasActor("changes")) { + return; + } + + const changesFront = await targetFront.getFront("changes"); + + // Get all changes collected up to this point by the ChangesActor on the server, + // then fire each change as "add-change". + const changes = await changesFront.allChanges(); + await onAvailable(changes.map(change => toResource(change))); + + changesFront.on("add-change", change => onAvailable([toResource(change)])); +}; + +function toResource(change) { + return Object.assign(change, { + resourceType: ResourceCommand.TYPES.CSS_CHANGE, + }); +} diff --git a/devtools/shared/commands/resource/legacy-listeners/error-messages.js b/devtools/shared/commands/resource/legacy-listeners/error-messages.js new file mode 100644 index 0000000000..5ba898c917 --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/error-messages.js @@ -0,0 +1,62 @@ +/* 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 ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); +const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js"); + +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + // Allow the top level target unconditionnally. + // Also allow frame, but only in content toolbox, i.e. still ignore them in + // the context of the browser toolbox as we inspect messages via the process + // targets + // Also ignore workers as they are not supported yet. (see bug 1592584) + const listenForFrames = targetCommand.descriptorFront.isTabDescriptor; + const isAllowed = + targetFront.isTopLevel || + targetFront.targetType === targetCommand.TYPES.PROCESS || + (targetFront.targetType === targetCommand.TYPES.FRAME && listenForFrames); + + if (!isAllowed) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new messages. Here the "PageError" type start listening for + // both actual PageErrors (emitted as "pageError" events) as well as LogMessages ( + // emitted as "logMessage" events). This function only set up the listener on the + // webConsoleFront for "pageError". + await webConsoleFront.startListeners(["PageError"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners("PageError") first /!\ + let { messages } = await webConsoleFront.getCachedMessages(["PageError"]); + + // On server < v79, we're also getting CSS Messages that we need to filter out. + messages = messages.filter( + message => message.pageError.category !== MESSAGE_CATEGORY.CSS_PARSER + ); + + messages.forEach(message => { + message.resourceType = ResourceCommand.TYPES.ERROR_MESSAGE; + }); + // Cached messages don't have the same shape as live messages, + // so we need to transform them. + onAvailable(messages); + + webConsoleFront.on("pageError", message => { + // On server < v79, we're getting CSS Messages that we need to filter out. + if (message.pageError.category === MESSAGE_CATEGORY.CSS_PARSER) { + return; + } + + message.resourceType = ResourceCommand.TYPES.ERROR_MESSAGE; + onAvailable([message]); + }); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/moz.build b/devtools/shared/commands/resource/legacy-listeners/moz.build new file mode 100644 index 0000000000..6ffb469891 --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/moz.build @@ -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/. + +DevToolsModules( + "console-messages.js", + "css-changes.js", + "error-messages.js", + "platform-messages.js", + "reflow.js", + "root-node.js", + "source.js", + "thread-states.js", +) diff --git a/devtools/shared/commands/resource/legacy-listeners/platform-messages.js b/devtools/shared/commands/resource/legacy-listeners/platform-messages.js new file mode 100644 index 0000000000..729696275e --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/platform-messages.js @@ -0,0 +1,44 @@ +/* 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 ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + // Only allow the top level target and processes. + // Frames can be ignored as logMessage are never sent to them anyway. + // Also ignore workers as they are not supported yet. (see bug 1592584) + const isAllowed = + targetFront.isTopLevel || + targetFront.targetType === targetCommand.TYPES.PROCESS; + if (!isAllowed) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + if (webConsoleFront.isDestroyed()) { + return; + } + + // Request notifying about new messages. Here the "PageError" type start listening for + // both actual PageErrors (emitted as "pageError" events) as well as LogMessages ( + // emitted as "logMessage" events). This function only set up the listener on the + // webConsoleFront for "logMessage". + await webConsoleFront.startListeners(["PageError"]); + + // Fetch already existing messages + // /!\ The actor implementation requires to call startListeners("PageError") first /!\ + const { messages } = await webConsoleFront.getCachedMessages(["LogMessage"]); + + for (const message of messages) { + message.resourceType = ResourceCommand.TYPES.PLATFORM_MESSAGE; + } + onAvailable(messages); + + webConsoleFront.on("logMessage", message => { + message.resourceType = ResourceCommand.TYPES.PLATFORM_MESSAGE; + onAvailable([message]); + }); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/reflow.js b/devtools/shared/commands/resource/legacy-listeners/reflow.js new file mode 100644 index 0000000000..63802f510d --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/reflow.js @@ -0,0 +1,24 @@ +/* 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 ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetFront, onAvailable }) { + if (!targetFront.getTrait("isBrowsingContext")) { + // The reflows only work with BrowsingContext targets + return; + } + const reflowFront = await targetFront.getFront("reflow"); + reflowFront.on("reflows", reflows => + onAvailable([ + { + resourceType: ResourceCommand.TYPES.REFLOW, + reflows, + }, + ]) + ); + await reflowFront.start(); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/root-node.js b/devtools/shared/commands/resource/legacy-listeners/root-node.js new file mode 100644 index 0000000000..6fa2bcbf22 --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/root-node.js @@ -0,0 +1,61 @@ +/* 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 ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetFront, onAvailable, onDestroyed }) { + // XXX: When watching root node for a non top-level target, this will also + // ensure the inspector & walker fronts for the target are initialized. + // This also implies that we call reparentRemoteFrame on the new walker, which + // will create the link between the parent frame NodeFront and the inner + // document NodeFront. + // + // This is not something that will work when the resource is moved to the + // server. When it becomes a server side resource, a RootNode would be emitted + // directly by the target actor. + // + // This probably means that the root node resource cannot remain a NodeFront. + // It should not be a front and the client should be responsible for + // retrieving the corresponding NodeFront. + // + // The other thing that we are missing with this patch is that we should only + // create inspector & walker fronts (and call reparentRemoteFrame) when we get + // a RootNode which is directly under an iframe node which is currently + // visible and tracked in the markup view. + // + // For instance, with the following markup: + // html + // body + // div + // iframe + // remote doc + // + // If the markup view only sees nodes down to `div`, then the client is not + // currently tracking the nodeFront for the `iframe`, and getting a new root + // node for the remote document should NOT force the iframe to be tracked on + // on the client. + // + // When we get a RootNode resource, we will need a way to check this before + // initializing & reparenting the walker. + // + if (!targetFront.getTrait("isBrowsingContext")) { + // The root-node resource is only available on browsing-context targets. + return; + } + + const inspectorFront = await targetFront.getFront("inspector"); + inspectorFront.walker.on("root-available", node => { + node.resourceType = ResourceCommand.TYPES.ROOT_NODE; + return onAvailable([node]); + }); + + inspectorFront.walker.on("root-destroyed", node => { + node.resourceType = ResourceCommand.TYPES.ROOT_NODE; + return onDestroyed([node]); + }); + + await inspectorFront.walker.watchRootNode(); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/source.js b/devtools/shared/commands/resource/legacy-listeners/source.js new file mode 100644 index 0000000000..45ee62f70f --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/source.js @@ -0,0 +1,88 @@ +/* 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 ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +/** + * Emit SOURCE resources, which represents a Javascript source and has the following attributes set on "available": + * + * - introductionType {null|String}: A string indicating how this source code was introduced into the system. + * This will typically be set to "scriptElement", "eval", ... + * But this may have many other values: + * https://searchfox.org/mozilla-central/rev/ac142717cc067d875e83e4b1316f004f6e063a46/dom/script/ScriptLoader.cpp#2628-2639 + * https://searchfox.org/mozilla-central/search?q=symbol:_ZN2JS14CompileOptions19setIntroductionTypeEPKc&redirect=false + * https://searchfox.org/mozilla-central/rev/ac142717cc067d875e83e4b1316f004f6e063a46/devtools/server/actors/source.js#160-169 + * - sourceMapBaseURL {String}: Base URL where to look for a source map. + * This isn't the source map URL. + * - sourceMapURL {null|String}: URL of the source map, if there is one. + * - url {null|String}: URL of the source, if it relates to a particular URL. + * Evaled sources won't have any related URL. + * - isBlackBoxed {Boolean}: Specifying whether the source actor's 'black-boxed' flag is set. + * - extensionName {null|String}: If the source comes from an add-on, the add-on name. + */ +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + const isBrowserToolbox = + targetCommand.descriptorFront.isBrowserProcessDescriptor; + const isNonTopLevelFrameTarget = + !targetFront.isTopLevel && + targetFront.targetType === targetCommand.TYPES.FRAME; + + if (isBrowserToolbox && isNonTopLevelFrameTarget) { + // In the BrowserToolbox, non-top-level frame targets are already + // debugged via content-process targets. + return; + } + + const threadFront = await targetFront.getFront("thread"); + + // Use a list of all notified SourceFront as we don't have a newSource event for all sources + // but we sometime get sources notified both via newSource event *and* sources() method... + // We store actor ID instead of SourceFront as it appears that multiple SourceFront for the same + // actor are created... + const sourcesActorIDCache = new Set(); + + // Forward new sources (but also existing ones, see next comment) + threadFront.on("newSource", ({ source }) => { + if (sourcesActorIDCache.has(source.actor)) { + return; + } + sourcesActorIDCache.add(source.actor); + // source is a SourceActor's form, add the resourceType attribute on it + source.resourceType = ResourceCommand.TYPES.SOURCE; + onAvailable([source]); + }); + + // Forward already existing sources + // Note that calling `sources()` will end up emitting `newSource` event for all existing sources. + // But not in some cases, for example, when the thread is already paused. + // (And yes, it means that already existing sources can be transfered twice over the wire) + // + // Also, browser_ext_devtools_inspectedWindow_targetSwitch.js creates many top level targets, + // for which the SourceMapURLService will fetch sources. But these targets are destroyed while + // the test is running and when they are, we purge all pending requests, including this one. + // So ignore any error if this request failed on destruction. + let sources; + try { + sources = await threadFront.sources(); + } catch (e) { + if (threadFront.isDestroyed()) { + return; + } + throw e; + } + + // Note that `sources()` doesn't encapsulate SourceFront into a `source` attribute + // while `newSource` event does. + sources = sources.filter(source => { + return !sourcesActorIDCache.has(source.actor); + }); + for (const source of sources) { + sourcesActorIDCache.add(source.actor); + // source is a SourceActor's form, add the resourceType attribute on it + source.resourceType = ResourceCommand.TYPES.SOURCE; + } + onAvailable(sources); +}; diff --git a/devtools/shared/commands/resource/legacy-listeners/thread-states.js b/devtools/shared/commands/resource/legacy-listeners/thread-states.js new file mode 100644 index 0000000000..42c922072a --- /dev/null +++ b/devtools/shared/commands/resource/legacy-listeners/thread-states.js @@ -0,0 +1,81 @@ +/* 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 ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); + +module.exports = async function ({ targetCommand, targetFront, onAvailable }) { + const isBrowserToolbox = + targetCommand.descriptorFront.isBrowserProcessDescriptor; + const isNonTopLevelFrameTarget = + !targetFront.isTopLevel && + targetFront.targetType === targetCommand.TYPES.FRAME; + + if (isBrowserToolbox && isNonTopLevelFrameTarget) { + // In the BrowserToolbox, non-top-level frame targets are already + // debugged via content-process targets. + return; + } + + // Wait for the thread actor to be attached, otherwise getFront(thread) will throw for worker targets + // This is because worker target are still kind of descriptors and are only resolved into real target + // after being attached. And the thread actor ID is only retrieved and available after being attached. + await targetFront.onThreadAttached; + + if (targetFront.isDestroyed()) { + return; + } + const threadFront = await targetFront.getFront("thread"); + + let isInterrupted = false; + const onPausedPacket = packet => { + // If paused by an explicit interrupt, which are generated by the + // slow script dialog and internal events such as setting + // breakpoints, ignore the event. + const { why } = packet; + if (why.type === "interrupted" && !why.onNext) { + isInterrupted = true; + return; + } + + // Ignore attached events because they are not useful to the user. + if (why.type == "alreadyPaused" || why.type == "attached") { + return; + } + + onAvailable([ + { + resourceType: ResourceCommand.TYPES.THREAD_STATE, + state: "paused", + why, + frame: packet.frame, + }, + ]); + }; + threadFront.on("paused", onPausedPacket); + + threadFront.on("resumed", packet => { + // NOTE: the client suppresses resumed events while interrupted + // to prevent unintentional behavior. + // see [client docs](devtools/client/debugger/src/client/README.md#interrupted) for more information. + if (isInterrupted) { + isInterrupted = false; + return; + } + + onAvailable([ + { + resourceType: ResourceCommand.TYPES.THREAD_STATE, + state: "resumed", + }, + ]); + }); + + // Notify about already paused thread + const pausedPacket = threadFront.getLastPausePacket(); + if (pausedPacket) { + onPausedPacket(pausedPacket); + } +}; diff --git a/devtools/shared/commands/resource/moz.build b/devtools/shared/commands/resource/moz.build new file mode 100644 index 0000000000..24112de5c1 --- /dev/null +++ b/devtools/shared/commands/resource/moz.build @@ -0,0 +1,15 @@ +# 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 += [ + "legacy-listeners", + "transformers", +] + +DevToolsModules( + "resource-command.js", +) + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser.ini"] diff --git a/devtools/shared/commands/resource/resource-command.js b/devtools/shared/commands/resource/resource-command.js new file mode 100644 index 0000000000..1eb9dd40ae --- /dev/null +++ b/devtools/shared/commands/resource/resource-command.js @@ -0,0 +1,1352 @@ +/* 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"); + +let gLastResourceId = 0; + +function cacheKey(resourceType, resourceId) { + return `${resourceType}:${resourceId}`; +} + +class ResourceCommand { + /** + * This class helps retrieving existing and listening to resources. + * A resource is something that: + * - the target you are debugging exposes + * - can be created as early as the process/worker/page starts loading + * - can already exist, or will be created later on + * - doesn't require any user data to be fetched, only a type/category + * + * @param object commands + * The commands object with all interfaces defined from devtools/shared/commands/ + */ + constructor({ commands }) { + this.targetCommand = commands.targetCommand; + + this._onTargetAvailable = this._onTargetAvailable.bind(this); + this._onTargetDestroyed = this._onTargetDestroyed.bind(this); + + this._onResourceAvailable = this._onResourceAvailable.bind(this); + this._onResourceDestroyed = this._onResourceDestroyed.bind(this); + + // Array of all the currently registered watchers, which contains object with attributes: + // - {String} resources: list of all resource watched by this one watcher + // - {Function} onAvailable: watcher's function to call when a new resource is available + // - {Function} onUpdated: watcher's function to call when a resource has been updated + // - {Function} onDestroyed: watcher's function to call when a resource is destroyed + this._watchers = []; + + // Set of watchers currently going through watchResources, only used to handle + // early calls to unwatchResources. Using a Set instead of an array for easier + // delete operations. + this._pendingWatchers = new Set(); + + // Caches for all resources by the order that the resource was taken. + this._cache = new Map(); + this._listenedResources = new Set(); + + // WeakMap used to avoid starting a legacy listener twice for the same + // target + resource-type pair. Legacy listener creation can be subject to + // race conditions. + // Maps a target front to an array of resource types. + this._existingLegacyListeners = new WeakMap(); + this._processingExistingResources = new Set(); + + // List of targetFront event listener unregistration functions keyed by target front. + // These are called when unwatching resources, so if a consumer starts watching resources again, + // we don't have listeners registered twice. + this._offTargetFrontListeners = new Map(); + + this._notifyWatchers = this._notifyWatchers.bind(this); + this._throttledNotifyWatchers = throttle(this._notifyWatchers, 100); + } + + get watcherFront() { + return this.targetCommand.watcherFront; + } + + addResourceToCache(resource) { + const { resourceId, resourceType } = resource; + this._cache.set(cacheKey(resourceType, resourceId), resource); + } + + /** + * Clear all the resources related to specifed resource types. + * Should also trigger clearing of the caches that exists on the related + * serverside resource watchers. + * + * @param {Array:string} resourceTypes + * A list of all the resource types whose + * resources shouled be cleared. + */ + async clearResources(resourceTypes) { + if (!Array.isArray(resourceTypes)) { + throw new Error("clearResources expects a list of resources types"); + } + // Clear the cached resources of the type. + for (const [key, resource] of this._cache) { + if (resourceTypes.includes(resource.resourceType)) { + // NOTE: To anyone paranoid like me, yes it is okay to delete from a Map while iterating it. + this._cache.delete(key); + } + } + + const resourcesToClear = resourceTypes.filter(resourceType => + this.hasResourceCommandSupport(resourceType) + ); + if (resourcesToClear.length) { + this.watcherFront.clearResources(resourcesToClear); + } + } + /** + * Return all specified resources cached in this watcher. + * + * @param {String} resourceType + * @return {Array} resources cached in this watcher + */ + getAllResources(resourceType) { + const result = []; + for (const resource of this._cache.values()) { + if (resource.resourceType === resourceType) { + result.push(resource); + } + } + return result; + } + + /** + * Return the specified resource cached in this watcher. + * + * @param {String} resourceType + * @param {String} resourceId + * @return {Object} resource cached in this watcher + */ + getResourceById(resourceType, resourceId) { + return this._cache.get(cacheKey(resourceType, resourceId)); + } + + /** + * Request to start retrieving all already existing instances of given + * type of resources and also start watching for the one to be created after. + * + * @param {Array:string} resources + * List of all resources which should be fetched and observed. + * @param {Object} options + * - {Function} onAvailable: This attribute is mandatory. + * Function which will be called with an array of resources + * each time resource(s) are created. + * A second dictionary argument with `areExistingResources` boolean + * attribute helps knowing if that's live resources, or some coming + * from ResourceCommand cache. + * - {Function} onUpdated: This attribute is optional. + * Function which will be called with an array of updates resources + * each time resource(s) are updated. + * These resources were previously notified via onAvailable. + * - {Function} onDestroyed: This attribute is optional. + * Function which will be called with an array of deleted resources + * each time resource(s) are destroyed. + * - {boolean} ignoreExistingResources: + * This attribute is optional. Default value is false. + * If set to true, onAvailable won't be called with + * existing resources. + */ + async watchResources(resources, options) { + const { + onAvailable, + onUpdated, + onDestroyed, + ignoreExistingResources = false, + } = options; + + if (typeof onAvailable !== "function") { + throw new Error( + "ResourceCommand.watchResources expects an onAvailable function as argument" + ); + } + + for (const type of resources) { + if (!this._isValidResourceType(type)) { + throw new Error( + `ResourceCommand.watchResources invoked with an unknown type: "${type}"` + ); + } + } + + // Pending watchers are used in unwatchResources to remove watchers which + // are not fully registered yet. Store `onAvailable` which is the unique key + // for a watcher, as well as the resources array, so that unwatchResources + // can update the array if we stop watching a specific resource. + const pendingWatcher = { + resources, + onAvailable, + }; + this._pendingWatchers.add(pendingWatcher); + + // Bug 1675763: Watcher actor is not available in all situations yet. + if (!this._listenerRegistered && this.watcherFront) { + this._listenerRegistered = true; + // Resources watched from the parent process will be emitted on the Watcher Actor. + // So that we also have to listen for this event on it, in addition to all targets. + this.watcherFront.on( + "resource-available-form", + this._onResourceAvailable.bind(this, { + watcherFront: this.watcherFront, + }) + ); + this.watcherFront.on( + "resource-updated-form", + this._onResourceUpdated.bind(this, { watcherFront: this.watcherFront }) + ); + this.watcherFront.on( + "resource-destroyed-form", + this._onResourceDestroyed.bind(this, { + watcherFront: this.watcherFront, + }) + ); + } + + const promises = []; + for (const resource of resources) { + promises.push(this._startListening(resource)); + } + await Promise.all(promises); + + // The resource cache is immediately filled when receiving the sources, but they are + // emitted with a delay due to throttling. Since the cache can contain resources that + // will soon be emitted, we have to flush it before adding the new listeners. + // Otherwise _forwardExistingResources might emit resources that will also be emitted by + // the next `_notifyWatchers` call done when calling `_startListening`, which will pull the + // "already existing" resources. + this._notifyWatchers(); + + // Update the _pendingWatchers set before adding the watcher to _watchers. + this._pendingWatchers.delete(pendingWatcher); + + // If unwatchResources was called in the meantime, use pendingWatcher's + // resources to get the updated list of watched resources. + const watchedResources = pendingWatcher.resources; + + // If no resource needs to be watched anymore, do not add an empty watcher + // to _watchers, and do not notify about cached resources. + if (!watchedResources.length) { + return; + } + + // Register the watcher just after calling _startListening in order to avoid it being called + // for already existing resources, which will optionally be notified via _forwardExistingResources + this._watchers.push({ + resources: watchedResources, + onAvailable, + onUpdated, + onDestroyed, + pendingEvents: [], + }); + + if (!ignoreExistingResources) { + await this._forwardExistingResources(watchedResources, onAvailable); + } + } + + /** + * Stop watching for given type of resources. + * See `watchResources` for the arguments as both methods receive the same. + * Note that `onUpdated` and `onDestroyed` attributes of `options` aren't used here. + * Only `onAvailable` attribute is looked up and we unregister all the other registered callbacks + * when a matching available callback is found. + */ + unwatchResources(resources, options) { + const { onAvailable } = options; + + if (typeof onAvailable !== "function") { + throw new Error( + "ResourceCommand.unwatchResources expects an onAvailable function as argument" + ); + } + + for (const type of resources) { + if (!this._isValidResourceType(type)) { + throw new Error( + `ResourceCommand.unwatchResources invoked with an unknown type: "${type}"` + ); + } + } + + // Unregister the callbacks from the watchers registries. + // Check _watchers for the fully initialized watchers, as well as + // `_pendingWatchers` for new watchers still being created by `watchResources` + const allWatchers = [...this._watchers, ...this._pendingWatchers]; + for (const watcherEntry of allWatchers) { + // onAvailable is the only mandatory argument which ends up being used to match + // the right watcher entry. + if (watcherEntry.onAvailable == onAvailable) { + // Remove all resources that we stop watching. We may still watch for some others. + watcherEntry.resources = watcherEntry.resources.filter(resourceType => { + return !resources.includes(resourceType); + }); + } + } + this._watchers = this._watchers.filter(entry => { + // Remove entries entirely if it isn't watching for any resource type + return !!entry.resources.length; + }); + + // Stop listening to all resources for which we removed the last watcher + for (const resource of resources) { + const isResourceWatched = allWatchers.some(watcherEntry => + watcherEntry.resources.includes(resource) + ); + + // Also check in _listenedResources as we may call unwatchResources + // for resources that we haven't started watching for. + if (!isResourceWatched && this._listenedResources.has(resource)) { + this._stopListening(resource); + } + } + + // Stop watching for targets if we removed the last listener. + if (this._listenedResources.size == 0) { + this._unwatchAllTargets(); + } + } + + /** + * Wait for a single resource of the provided resourceType. + * + * @param {String} resourceType + * One of ResourceCommand.TYPES, type of the expected resource. + * @param {Object} additional options + * - {Boolean} ignoreExistingResources: ignore existing resources or not. + * - {Function} predicate: if provided, will wait until a resource makes + * predicate(resource) return true. + * @return {Promise} + * Return a promise which resolves once we fully settle the resource listener. + * You should await for its resolution before doing the action which may fire + * your resource. + * This promise will expose an object with `onResource` attribute, + * itself being a promise, which will resolve once a matching resource is received. + */ + async waitForNextResource( + resourceType, + { ignoreExistingResources = false, predicate } = {} + ) { + // If no predicate was provided, convert to boolean to avoid resolving for + // empty `resources` arrays. + 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 }; + } + + /** + * Check if there are any watchers for the specified resource. + * + * @param {String} resourceType + * One of ResourceCommand.TYPES + * @return {Boolean} + * If the resources type is beibg watched. + */ + isResourceWatched(resourceType) { + return this._listenedResources.has(resourceType); + } + + /** + * Start watching for all already existing and future targets. + * + * We are using ALL_TYPES, but this won't force listening to all types. + * It will only listen for types which are defined by `TargetCommand.startListening`. + */ + async _watchAllTargets() { + if (!this._watchTargetsPromise) { + // If this is the very first listener registered, of all kind of resource types: + // * we want to start observing targets via TargetCommand + // * _onTargetAvailable will be called for each already existing targets and the next one to come + this._watchTargetsPromise = this.targetCommand.watchTargets({ + types: this.targetCommand.ALL_TYPES, + onAvailable: this._onTargetAvailable, + onDestroyed: this._onTargetDestroyed, + }); + } + return this._watchTargetsPromise; + } + + _unwatchAllTargets() { + if (!this._watchTargetsPromise) { + return; + } + + for (const offList of this._offTargetFrontListeners.values()) { + offList.forEach(off => off()); + } + this._offTargetFrontListeners.clear(); + + this._watchTargetsPromise = null; + this.targetCommand.unwatchTargets({ + types: this.targetCommand.ALL_TYPES, + onAvailable: this._onTargetAvailable, + onDestroyed: this._onTargetDestroyed, + }); + } + + /** + * For a given resource type, start the legacy listeners for all already existing targets. + * Do that only if we have to. If this resourceType requires legacy listeners. + */ + async _startLegacyListenersForExistingTargets(resourceType) { + // If we were already listening to targets, we want to start the legacy listeners + // for all already existing targets. + const shouldRunLegacyListeners = + !this.hasResourceCommandSupport(resourceType) || + this._shouldRunLegacyListenerEvenWithWatcherSupport(resourceType); + if (shouldRunLegacyListeners) { + const promises = []; + const targets = this.targetCommand.getAllTargets( + this.targetCommand.ALL_TYPES + ); + for (const targetFront of targets) { + // We disable warning in case we already registered the legacy listener for this target + // as this code may race with the call from onTargetAvailable if we end up having multiple + // calls to _startListening in parallel. + promises.push( + this._watchResourcesForTarget({ + targetFront, + resourceType, + disableWarning: true, + }) + ); + } + await Promise.all(promises); + } + } + + /** + * Method called by the TargetCommand for each already existing or target which has just been created. + * + * @param {Object} arg + * @param {Front} arg.targetFront + * The Front of the target that is available. + * This Front inherits from TargetMixin and is typically + * composed of a WindowGlobalTargetFront or ContentProcessTargetFront. + * @param {Boolean} arg.isTargetSwitching + * true when the new target was created because of a target switching. + */ + async _onTargetAvailable({ targetFront, isTargetSwitching }) { + const resources = []; + if (isTargetSwitching) { + // WatcherActor currently only watches additional frame targets and + // explicitely ignores top level one that may be created when navigating + // to a new process. + // In order to keep working resources that are being watched via the + // Watcher actor, we have to unregister and re-register the resource + // types. This will force calling `Resources.watchResources` on the new top + // level target. + for (const resourceType of Object.values(ResourceCommand.TYPES)) { + // ...which has at least one listener... + if (!this._listenedResources.has(resourceType)) { + continue; + } + + if (this._shouldRestartListenerOnTargetSwitching(resourceType)) { + this._stopListening(resourceType, { + bypassListenerCount: true, + }); + resources.push(resourceType); + } + } + } + + if (targetFront.isDestroyed()) { + return; + } + + // If we are target switching, we already stop & start listening to all the + // currently monitored resources. + if (!isTargetSwitching) { + // For each resource type... + for (const resourceType of Object.values(ResourceCommand.TYPES)) { + // ...which has at least one listener... + if (!this._listenedResources.has(resourceType)) { + continue; + } + // ...request existing resource and new one to come from this one target + // *but* only do that for backward compat, where we don't have the watcher API + // (See bug 1626647) + await this._watchResourcesForTarget({ targetFront, resourceType }); + } + } + + // Compared to the TargetCommand and Watcher.watchTargets, + // We do call Watcher.watchResources, but the events are fired on the target. + // That's because the Watcher runs in the parent process/main thread, while resources + // are available from the target's process/thread. + const offResourceAvailable = targetFront.on( + "resource-available-form", + this._onResourceAvailable.bind(this, { targetFront }) + ); + const offResourceUpdated = targetFront.on( + "resource-updated-form", + this._onResourceUpdated.bind(this, { targetFront }) + ); + const offResourceDestroyed = targetFront.on( + "resource-destroyed-form", + this._onResourceDestroyed.bind(this, { targetFront }) + ); + + const offList = this._offTargetFrontListeners.get(targetFront) || []; + offList.push( + offResourceAvailable, + offResourceUpdated, + offResourceDestroyed + ); + + if (isTargetSwitching) { + await Promise.all( + resources.map(resourceType => + this._startListening(resourceType, { + bypassListenerCount: true, + }) + ) + ); + } + + // DOCUMENT_EVENT's will-navigate should replace target actor's will-navigate event, + // but only for targets provided by the watcher actor. + // Emit a fake DOCUMENT_EVENT's "will-navigate" out of target actor's will-navigate + // until watcher actor is supported by all descriptors (bug 1675763). + if (!this.targetCommand.hasTargetWatcherSupport()) { + const offWillNavigate = targetFront.on( + "will-navigate", + ({ url, isFrameSwitching }) => { + targetFront.emit("resource-available-form", [ + { + resourceType: this.TYPES.DOCUMENT_EVENT, + name: "will-navigate", + time: Date.now(), // will-navigate was not passing any timestamp + isFrameSwitching, + newURI: url, + }, + ]); + } + ); + offList.push(offWillNavigate); + } + + this._offTargetFrontListeners.set(targetFront, offList); + } + + _shouldRestartListenerOnTargetSwitching(resourceType) { + // Note that we aren't using isServerTargetSwitchingEnabled, nor checking the + // server side target switching preference as we may have server side targets + // even when this is false/disabled. + // This will happen for bfcache navigations, even with server side targets disabled. + // `followWindowGlobalLifeCycle` will be false for the first top level target + // and only become true when doing a bfcache navigation. + // (only server side targets follow the WindowGlobal lifecycle) + // When server side targets are enabled, this will always be true. + const isServerSideTarget = + this.targetCommand.targetFront.targetForm.followWindowGlobalLifeCycle; + if (isServerSideTarget) { + // For top-level targets created from the server, only restart legacy + // listeners. + return !this.hasResourceCommandSupport(resourceType); + } + + // For top-level targets created from the client we should always restart + // listeners. + return true; + } + + /** + * Method called by the TargetCommand when a target has just been destroyed + * @param {Object} arg + * @param {Front} arg.targetFront + * The Front of the target that was destroyed + * @param {Boolean} arg.isModeSwitching + * true when this is called as the result of a change to the devtools.browsertoolbox.scope pref. + */ + _onTargetDestroyed({ targetFront, isModeSwitching }) { + // Clear the map of legacy listeners for this target. + this._existingLegacyListeners.set(targetFront, []); + this._offTargetFrontListeners.delete(targetFront); + + // Purge the cache from any resource related to the destroyed target. + // Top level BrowsingContext target will be purge via DOCUMENT_EVENT will-navigate events. + // If we were to clean resources from target-destroyed, we will clear resources + // happening between will-navigate and target-destroyed. Typically the navigation request + // At the moment, isModeSwitching can only be true when targetFront.isTopLevel isn't true, + // so we don't need to add a specific check for isModeSwitching. + if (!targetFront.isTopLevel || !targetFront.isBrowsingContext) { + for (const [key, resource] of this._cache) { + if (resource.targetFront === targetFront) { + // NOTE: To anyone paranoid like me, yes it is okay to delete from a Map while iterating it. + this._cache.delete(key); + } + } + } + + // Purge "available" pendingEvents for resources from the destroyed target when switching + // mode as we want to ignore those. + if (isModeSwitching) { + for (const watcherEntry of this._watchers) { + for (const pendingEvent of watcherEntry.pendingEvents) { + if (pendingEvent.callbackType == "available") { + pendingEvent.updates = pendingEvent.updates.filter( + update => update.targetFront !== targetFront + ); + } + } + } + } + } + + /** + * Method called either by: + * - the backward compatibility code (LegacyListeners) + * - target actors RDP events + * whenever an already existing resource is being listed or when a new one + * has been created. + * + * @param {Object} source + * A dictionary object with only one of these two attributes: + * - targetFront: a Target Front, if the resource is watched from the target process or thread + * - watcherFront: a Watcher Front, if the resource is watched from the parent process + * @param {Array} resources + * Depending on the resource Type, it can be an Array composed of either JSON objects or Fronts, + * which describes the resource. + */ + async _onResourceAvailable({ targetFront, watcherFront }, resources) { + let includesDocumentEventWillNavigate = false; + let includesDocumentEventDomLoading = false; + for (let resource of resources) { + const { resourceType } = resource; + + if (watcherFront) { + targetFront = await this._getTargetForWatcherResource(resource); + // When we receive resources from the Watcher actor, + // there is no guarantee that the target front is fully initialized. + // The Target Front is initialized by the TargetCommand, by calling TargetFront.attachAndInitThread. + // We have to wait for its completion as resources watchers are expecting it to be completed. + // + // But when navigating, we may receive resources packets for a destroyed target. + // Or, in the context of the browser toolbox, they may not relate to any target. + if (targetFront) { + await targetFront.initialized; + } + } + + // isAlreadyExistingResource indicates that the resources already existed before + // the resource command started watching for this type of resource. + resource.isAlreadyExistingResource = + this._processingExistingResources.has(resourceType); + + // Put the targetFront on the resource for easy retrieval. + // (Resources from the legacy listeners may already have the attribute set) + if (!resource.targetFront) { + resource.targetFront = targetFront; + } + + if (ResourceTransformers[resourceType]) { + resource = ResourceTransformers[resourceType]({ + resource, + targetCommand: this.targetCommand, + targetFront, + watcherFront: this.watcherFront, + }); + } + + if (!resource.resourceId) { + resource.resourceId = `auto:${++gLastResourceId}`; + } + + // Only consider top level document, and ignore remote iframes top document + const isWillNavigate = + resourceType == ResourceCommand.TYPES.DOCUMENT_EVENT && + resource.name == "will-navigate"; + if (isWillNavigate && resource.targetFront.isTopLevel) { + includesDocumentEventWillNavigate = true; + this._onWillNavigate(resource.targetFront); + } + + if ( + resourceType == ResourceCommand.TYPES.DOCUMENT_EVENT && + resource.name == "dom-loading" && + resource.targetFront.isTopLevel + ) { + includesDocumentEventDomLoading = true; + } + + this._queueResourceEvent("available", resourceType, resource); + + // Avoid storing will-navigate resource and consider it as a transcient resource. + // We do that to prevent leaking this resource (and its target) on navigation. + // We do clear the cache in _onWillNavigate, that we call a few lines before this. + if (!isWillNavigate) { + this.addResourceToCache(resource); + } + } + + // If we receive the DOCUMENT_EVENT for: + // - will-navigate + // - dom-loading + we're using the service worker legacy listener + // then flush immediately the resources to notify about the navigation sooner than later. + // (this is especially useful for tests, even if they should probably avoid depending on this...) + if ( + includesDocumentEventWillNavigate || + (includesDocumentEventDomLoading && + !this.targetCommand.hasTargetWatcherSupport("service_worker")) + ) { + this._notifyWatchers(); + } else { + this._throttledNotifyWatchers(); + } + } + + /** + * Method called either by: + * - the backward compatibility code (LegacyListeners) + * - target actors RDP events + * Called everytime a resource is updated in the remote target. + * + * @param {Object} source + * Please see _onResourceAvailable for this parameter. + * @param {Array} updates + * Depending on the listener. + * + * Among the element in the array, the following attributes are given special handling. + * - resourceType {String}: + * The type of resource to be updated. + * - resourceId {String}: + * The id of resource to be updated. + * - resourceUpdates {Object}: + * If resourceUpdates is in the element, a cached resource specified by resourceType + * and resourceId is updated by Object.assign(cachedResource, resourceUpdates). + * - nestedResourceUpdates {Object}: + * If `nestedResourceUpdates` is passed, update one nested attribute with a new value + * This allows updating one attribute of an object stored in a resource's attribute, + * as well as adding new elements to arrays. + * `path` is an array mentioning all nested attribute to walk through. + * `value` is the new nested attribute value to set. + * + * And also, the element is passed to the listener as it is as “update” object. + * So if we don't want to update a cached resource but have information want to + * pass on to the listener, can pass it on using attributes other than the ones + * listed above. + * For example, if the element consists of like + * "{ resourceType:… resourceId:…, testValue: “test”, }”, + * the listener can receive the value as follows. + * + * onResourceUpdate({ update }) { + * console.log(update.testValue); // “test” should be displayed + * } + */ + async _onResourceUpdated({ targetFront, watcherFront }, updates) { + for (const update of updates) { + const { + resourceType, + resourceId, + resourceUpdates, + nestedResourceUpdates, + } = update; + + if (!resourceId) { + console.warn(`Expected resource ${resourceType} to have a resourceId`); + } + + // See _onResourceAvailable() + // We also need to wait for the related targetFront to be initialized + // otherwise we would notify about the udpate *before* the available + // and the resource won't be in _cache. + if (watcherFront) { + targetFront = await this._getTargetForWatcherResource(update); + // When we receive the navigation request, the target front has already been + // destroyed, but this is fine. The cached resource has the reference to + // the (destroyed) target front and it is fully initialized. + if (targetFront) { + await targetFront.initialized; + } + } + + const existingResource = this._cache.get( + cacheKey(resourceType, resourceId) + ); + if (!existingResource) { + continue; + } + + if (resourceUpdates) { + Object.assign(existingResource, resourceUpdates); + } + + if (nestedResourceUpdates) { + for (const { path, value } of nestedResourceUpdates) { + let target = existingResource; + + for (let i = 0; i < path.length - 1; i++) { + target = target[path[i]]; + } + + target[path[path.length - 1]] = value; + } + } + this._queueResourceEvent("updated", resourceType, { + resource: existingResource, + update, + }); + } + this._throttledNotifyWatchers(); + } + + /** + * Called everytime a resource is destroyed in the remote target. + * See _onResourceAvailable for the argument description. + */ + async _onResourceDestroyed({ targetFront, watcherFront }, resources) { + for (const resource of resources) { + const { resourceType, resourceId } = resource; + this._cache.delete(cacheKey(resourceType, resourceId)); + this._queueResourceEvent("destroyed", resourceType, resource); + } + this._throttledNotifyWatchers(); + } + + _queueResourceEvent(callbackType, resourceType, update) { + for (const { resources, pendingEvents } of this._watchers) { + // This watcher doesn't listen to this type of resource + if (!resources.includes(resourceType)) { + continue; + } + // If we receive a new event of the same type, accumulate the new update in the last event + if (pendingEvents.length) { + const lastEvent = pendingEvents[pendingEvents.length - 1]; + if (lastEvent.callbackType == callbackType) { + lastEvent.updates.push(update); + continue; + } + } + // Otherwise, pile up a new event, which will force calling watcher + // callback a new time + pendingEvents.push({ + callbackType, + updates: [update], + }); + } + } + + /** + * Flush the pending event and notify all the currently registered watchers + * about all the available, updated and destroyed events that have been accumulated in + * `_watchers`'s `pendingEvents` arrays. + */ + _notifyWatchers() { + for (const watcherEntry of this._watchers) { + const { onAvailable, onUpdated, onDestroyed, pendingEvents } = + watcherEntry; + // Immediately clear the buffer in order to avoid possible races, where an event listener + // would end up somehow adding a new throttled resource + watcherEntry.pendingEvents = []; + + for (const { callbackType, updates } of pendingEvents) { + try { + if (callbackType == "available") { + onAvailable(updates, { areExistingResources: false }); + } else if (callbackType == "updated" && onUpdated) { + onUpdated(updates); + } else if (callbackType == "destroyed" && onDestroyed) { + onDestroyed(updates); + } + } catch (e) { + console.error( + "Exception while calling a ResourceCommand", + callbackType, + "callback", + ":", + e + ); + } + } + } + } + + // Compute the target front if the resource comes from the Watcher Actor. + // (`targetFront` will be null as the watcher is in the parent process + // and targets are in distinct processes) + _getTargetForWatcherResource(resource) { + const { browsingContextID, innerWindowId, resourceType } = resource; + + // Some privileged resources aren't related to any BrowsingContext + // and so aren't bound to any Target Front. + // Server watchers should pass an explicit "-1" value in order to prevent + // silently ignoring an undefined browsingContextID attribute. + if (browsingContextID == -1) { + return null; + } + + if (innerWindowId && this.targetCommand.isServerTargetSwitchingEnabled()) { + return this.watcherFront.getWindowGlobalTargetByInnerWindowId( + innerWindowId + ); + } else if (browsingContextID) { + return this.watcherFront.getWindowGlobalTarget(browsingContextID); + } + console.error( + `Resource of ${resourceType} is missing a browsingContextID or innerWindowId attribute` + ); + return null; + } + + _onWillNavigate(targetFront) { + // Special case for toolboxes debugging a document, + // purge the cache entirely when we start navigating to a new document. + // Other toolboxes and additional target for remote iframes or content process + // will be purge from onTargetDestroyed. + + // NOTE: we could `clear` the cache here, but technically if anything is + // currently iterating over resources provided by getAllResources, that + // would interfere with their iteration. We just assign a new Map here to + // leave those iterators as is. + this._cache = new Map(); + } + + /** + * Tells if the server supports listening to the given resource type + * via the watcher actor's watchResources method. + * + * @return {Boolean} True, if the server supports this type. + */ + hasResourceCommandSupport(resourceType) { + return this.watcherFront?.traits?.resources?.[resourceType]; + } + + /** + * Tells if the server supports listening to the given resource type + * via the watcher actor's watchResources method, and that, for a specific + * target. + * + * @return {Boolean} True, if the server supports this type. + */ + _hasResourceCommandSupportForTarget(resourceType, targetFront) { + // First check if the watcher supports this target type. + // If it doesn't, no resource type can be listened via the Watcher actor for this target. + if (!this.targetCommand.hasTargetWatcherSupport(targetFront.targetType)) { + return false; + } + + return this.hasResourceCommandSupport(resourceType); + } + + _isValidResourceType(type) { + return this.ALL_TYPES.includes(type); + } + + /** + * Start listening for a given type of resource. + * For backward compatibility code, we register the legacy listeners on + * each individual target + * + * @param {String} resourceType + * One string of ResourceCommand.TYPES, which designates the types of resources + * to be listened. + * @param {Object} + * - {Boolean} bypassListenerCount + * Pass true to avoid checking/updating the listenersCount map. + * Exclusively used when target switching, to stop & start listening + * to all resources. + */ + async _startListening(resourceType, { bypassListenerCount = false } = {}) { + if (!bypassListenerCount) { + if (this._listenedResources.has(resourceType)) { + return; + } + this._listenedResources.add(resourceType); + } + + this._processingExistingResources.add(resourceType); + + // Ensuring enabling listening to targets. + // This will be a no-op expect for the very first call to `_startListening`, + // where it is going to call `onTargetAvailable` for all already existing targets, + // as well as for those who will be created later. + // + // Do this *before* calling WatcherActor.watchResources in order to register "resource-available" + // listeners on targets before these events start being emitted. + await this._watchAllTargets(resourceType); + + // When we are calling _startListening for the first time, _watchAllTargets + // will register legacylistener when it will call onTargetAvailable for all existing targets. + // But for any next calls to _startListening, _watchAllTargets will be a no-op, + // and nothing will start legacy listener for each already registered targets. + await this._startLegacyListenersForExistingTargets(resourceType); + + // If the server supports the Watcher API and the Watcher supports + // this resource type, use this API + if (this.hasResourceCommandSupport(resourceType)) { + await this.watcherFront.watchResources([resourceType]); + } + this._processingExistingResources.delete(resourceType); + } + + /** + * Return true if the resource should be watched via legacy listener, + * even when watcher supports this resource type. + * + * Bug 1678385: In order to support watching for JS Source resource + * for service workers and parent process workers, which aren't supported yet + * by the watcher actor, we do not bail out here and allow to execute + * the legacy listener for these targets. + * Once bug 1608848 is fixed, we can remove this and never trigger + * the legacy listeners codepath for these resource types. + * + * If this isn't fixed soon, we may add other resources we want to see + * being fetched from these targets. + */ + _shouldRunLegacyListenerEvenWithWatcherSupport(resourceType) { + return WORKER_RESOURCE_TYPES.includes(resourceType); + } + + async _forwardExistingResources(resourceTypes, onAvailable) { + const existingResources = []; + for (const resource of this._cache.values()) { + if (resourceTypes.includes(resource.resourceType)) { + existingResources.push(resource); + } + } + if (existingResources.length) { + await onAvailable(existingResources, { areExistingResources: true }); + } + } + + /** + * Call backward compatibility code from `LegacyListeners` in order to listen for a given + * type of resource from a given target. + */ + async _watchResourcesForTarget({ + targetFront, + resourceType, + disableWarning = false, + }) { + if (this._hasResourceCommandSupportForTarget(resourceType, targetFront)) { + // This resource / target pair should already be handled by the watcher, + // no need to start legacy listeners. + return; + } + + // All workers target types are still not supported by the watcher + // so that we have to spawn legacy listener for all their resources. + // But some resources are irrelevant to workers, like network events. + // And we removed the related legacy listener as they are no longer used. + if ( + targetFront.targetType.endsWith("worker") && + !WORKER_RESOURCE_TYPES.includes(resourceType) + ) { + return; + } + + if (targetFront.isDestroyed()) { + return; + } + + const onAvailable = this._onResourceAvailable.bind(this, { targetFront }); + const onUpdated = this._onResourceUpdated.bind(this, { targetFront }); + const onDestroyed = this._onResourceDestroyed.bind(this, { targetFront }); + + if (!(resourceType in LegacyListeners)) { + throw new Error(`Missing legacy listener for ${resourceType}`); + } + + const legacyListeners = + this._existingLegacyListeners.get(targetFront) || []; + if (legacyListeners.includes(resourceType)) { + if (!disableWarning) { + console.warn( + `Already started legacy listener for ${resourceType} on ${targetFront.actorID}` + ); + } + return; + } + this._existingLegacyListeners.set( + targetFront, + legacyListeners.concat(resourceType) + ); + + try { + await LegacyListeners[resourceType]({ + targetCommand: this.targetCommand, + targetFront, + onAvailable, + onDestroyed, + onUpdated, + }); + } catch (e) { + // Swallow the error to avoid breaking calls to watchResources which will + // loop on all existing targets to create legacy listeners. + // If a legacy listener fails to handle a target for some reason, we + // should still try to process other targets as much as possible. + // See Bug 1687645. + console.error( + `Failed to start [${resourceType}] legacy listener for target ${targetFront.actorID}`, + e + ); + } + } + + /** + * Reverse of _startListening. Stop listening for a given type of resource. + * For backward compatibility, we unregister from each individual target. + * + * See _startListening for parameters description. + */ + _stopListening(resourceType, { bypassListenerCount = false } = {}) { + if (!bypassListenerCount) { + if (!this._listenedResources.has(resourceType)) { + throw new Error( + `Stopped listening for resource '${resourceType}' that isn't being listened to` + ); + } + this._listenedResources.delete(resourceType); + } + + // Clear the cached resources of the type. + for (const [key, resource] of this._cache) { + if (resource.resourceType == resourceType) { + // NOTE: To anyone paranoid like me, yes it is okay to delete from a Map while iterating it. + this._cache.delete(key); + } + } + + // If the server supports the Watcher API and the Watcher supports + // this resource type, use this API + if (this.hasResourceCommandSupport(resourceType)) { + if (!this.watcherFront.isDestroyed()) { + this.watcherFront.unwatchResources([resourceType]); + } + + const shouldRunLegacyListeners = + this._shouldRunLegacyListenerEvenWithWatcherSupport(resourceType); + if (!shouldRunLegacyListeners) { + return; + } + } + // Otherwise, fallback on backward compat mode and use LegacyListeners. + + // If this was the last listener, we should stop watching these events from the actors + // and the actors should stop watching things from the platform + const targets = this.targetCommand.getAllTargets( + this.targetCommand.ALL_TYPES + ); + for (const target of targets) { + this._unwatchResourcesForTarget(target, resourceType); + } + } + + /** + * Backward compatibility code, reverse of _watchResourcesForTarget. + */ + _unwatchResourcesForTarget(targetFront, resourceType) { + if (this._hasResourceCommandSupportForTarget(resourceType, targetFront)) { + // This resource / target pair should already be handled by the watcher, + // no need to stop legacy listeners. + } + // Is there really a point in: + // - unregistering `onAvailable` RDP event callbacks from target-scoped actors? + // - calling `stopListeners()` as we are most likely closing the toolbox and destroying everything? + // + // It is important to keep this method synchronous and do as less as possible + // in the case of toolbox destroy. + // + // We are aware of one case where that might be useful. + // When a panel is disabled via the options panel, after it has been opened. + // Would that justify doing this? Is there another usecase? + + // XXX: This is most likely only needed to avoid growing the Map infinitely. + // Unless in the "disabled panel" use case mentioned in the comment above, + // we should not see the same target actorID again. + const listeners = this._existingLegacyListeners.get(targetFront); + if (listeners && listeners.includes(resourceType)) { + const remainingListeners = listeners.filter(l => l !== resourceType); + this._existingLegacyListeners.set(targetFront, remainingListeners); + } + } +} + +ResourceCommand.TYPES = ResourceCommand.prototype.TYPES = { + CONSOLE_MESSAGE: "console-message", + CSS_CHANGE: "css-change", + CSS_MESSAGE: "css-message", + ERROR_MESSAGE: "error-message", + PLATFORM_MESSAGE: "platform-message", + DOCUMENT_EVENT: "document-event", + ROOT_NODE: "root-node", + STYLESHEET: "stylesheet", + NETWORK_EVENT: "network-event", + WEBSOCKET: "websocket", + COOKIE: "cookies", + LOCAL_STORAGE: "local-storage", + SESSION_STORAGE: "session-storage", + CACHE_STORAGE: "Cache", + EXTENSION_STORAGE: "extension-storage", + INDEXED_DB: "indexed-db", + NETWORK_EVENT_STACKTRACE: "network-event-stacktrace", + REFLOW: "reflow", + SOURCE: "source", + THREAD_STATE: "thread-state", + TRACING_STATE: "tracing-state", + SERVER_SENT_EVENT: "server-sent-event", + LAST_PRIVATE_CONTEXT_EXIT: "last-private-context-exit", +}; +ResourceCommand.ALL_TYPES = ResourceCommand.prototype.ALL_TYPES = Object.values( + ResourceCommand.TYPES +); +module.exports = ResourceCommand; + +// This is the list of resource types supported by workers. +// We need such list to know when forcing to run the legacy listeners +// and when to avoid try to spawn some unsupported ones for workers. +const WORKER_RESOURCE_TYPES = [ + ResourceCommand.TYPES.CONSOLE_MESSAGE, + ResourceCommand.TYPES.ERROR_MESSAGE, + ResourceCommand.TYPES.SOURCE, + ResourceCommand.TYPES.THREAD_STATE, +]; + +// Backward compat code for each type of resource. +// Each section added here should eventually be removed once the equivalent server +// code is implement in Firefox, in its release channel. +const LegacyListeners = { + async [ResourceCommand.TYPES.DOCUMENT_EVENT]({ + targetCommand, + targetFront, + onAvailable, + }) { + // DocumentEventsListener of webconsole handles only top level document. + if (!targetFront.isTopLevel) { + return; + } + + const webConsoleFront = await targetFront.getFront("console"); + webConsoleFront.on("documentEvent", event => { + event.resourceType = ResourceCommand.TYPES.DOCUMENT_EVENT; + onAvailable([event]); + }); + await webConsoleFront.startListeners(["DocumentEvents"]); + }, +}; +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.CONSOLE_MESSAGE, + "resource://devtools/shared/commands/resource/legacy-listeners/console-messages.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.CSS_CHANGE, + "resource://devtools/shared/commands/resource/legacy-listeners/css-changes.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.CSS_MESSAGE, + "resource://devtools/shared/commands/resource/legacy-listeners/css-messages.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.ERROR_MESSAGE, + "resource://devtools/shared/commands/resource/legacy-listeners/error-messages.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.PLATFORM_MESSAGE, + "resource://devtools/shared/commands/resource/legacy-listeners/platform-messages.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.ROOT_NODE, + "resource://devtools/shared/commands/resource/legacy-listeners/root-node.js" +); + +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.SOURCE, + "resource://devtools/shared/commands/resource/legacy-listeners/source.js" +); +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.THREAD_STATE, + "resource://devtools/shared/commands/resource/legacy-listeners/thread-states.js" +); + +loader.lazyRequireGetter( + LegacyListeners, + ResourceCommand.TYPES.REFLOW, + "resource://devtools/shared/commands/resource/legacy-listeners/reflow.js" +); + +// Optional transformers for each type of resource. +// Each module added here should be a function that will receive the resource, the target, … +// and perform some transformation on the resource before it will be emitted. +// This is a good place to handle backward compatibility and manual resource marshalling. +const ResourceTransformers = {}; + +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.CONSOLE_MESSAGE, + "resource://devtools/shared/commands/resource/transformers/console-messages.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.ERROR_MESSAGE, + "resource://devtools/shared/commands/resource/transformers/error-messages.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.CACHE_STORAGE, + "resource://devtools/shared/commands/resource/transformers/storage-cache.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.COOKIE, + "resource://devtools/shared/commands/resource/transformers/storage-cookie.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.EXTENSION_STORAGE, + "resource://devtools/shared/commands/resource/transformers/storage-extension.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.INDEXED_DB, + "resource://devtools/shared/commands/resource/transformers/storage-indexed-db.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.LOCAL_STORAGE, + "resource://devtools/shared/commands/resource/transformers/storage-local-storage.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.SESSION_STORAGE, + "resource://devtools/shared/commands/resource/transformers/storage-session-storage.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.NETWORK_EVENT, + "resource://devtools/shared/commands/resource/transformers/network-events.js" +); +loader.lazyRequireGetter( + ResourceTransformers, + ResourceCommand.TYPES.THREAD_STATE, + "resource://devtools/shared/commands/resource/transformers/thread-states.js" +); diff --git a/devtools/shared/commands/resource/tests/breakpoint_document.html b/devtools/shared/commands/resource/tests/breakpoint_document.html new file mode 100644 index 0000000000..2291094646 --- /dev/null +++ b/devtools/shared/commands/resource/tests/breakpoint_document.html @@ -0,0 +1,21 @@ + + + + + Test breakpoint document + + + + + + diff --git a/devtools/shared/commands/resource/tests/browser.ini b/devtools/shared/commands/resource/tests/browser.ini new file mode 100644 index 0000000000..6c89b01a69 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser.ini @@ -0,0 +1,82 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + !/devtools/client/shared/test/shared-head.js + !/devtools/client/shared/test/telemetry-test-helpers.js + !/devtools/client/shared/test/highlighter-test-actor.js + head.js + breakpoint_document.html + doc_console.html + doc_console_iframe.html + empty.html + network_document.html + network_document_navigation.html + network_navigation.js + early_console_document.html + fission_document.html + fission_document_workers.html + fission_iframe.html + fission_iframe_workers.html + service-worker-sources.js + sources.html + sources.js + sse_backend.sjs + sse_frontend_iframe.html + sse_frontend.html + style_document.css + style_document.html + style_iframe.css + style_iframe.html + stylesheets-nested-iframes.html + test_image.png + test_service_worker.js + test_worker.js + websocket_backend_wsh.py + websocket_frontend_iframe.html + websocket_frontend.html + worker-sources.js + +[browser_browser_resources_console_messages.js] +[browser_resources_clear_resources.js] +[browser_resources_client_caching.js] +[browser_resources_console_messages.js] +[browser_resources_console_messages_navigation.js] +[browser_resources_console_messages_workers.js] +[browser_resources_css_changes.js] +[browser_resources_css_messages.js] +[browser_resources_document_events.js] +skip-if = + win10_2004 # Bug 1723573 + os == 'linux' && bits == 64 # Bug 1715878 +[browser_resources_error_messages.js] +[browser_resources_getAllResources.js] +[browser_resources_invalid_api_usage.js] +[browser_resources_last_private_context_exit.js] +[browser_resources_network_event_stacktraces.js] +[browser_resources_network_events.js] +[browser_resources_network_events_cache.js] +[browser_resources_network_events_navigation.js] +[browser_resources_network_events_parent_process.js] +[browser_resources_platform_messages.js] +[browser_resources_reflows.js] +[browser_resources_root_node.js] +[browser_resources_scope_flag.js] +[browser_resources_server_sent_events.js] +[browser_resources_several_resources.js] +[browser_resources_sources.js] +skip-if = + os == "linux" && bits == 64 # Bug 1744565 + win10_2004 && !debug # Bug 1744565 +[browser_resources_stylesheets.js] +[browser_resources_stylesheets_import.js] +[browser_resources_stylesheets_navigation.js] +[browser_resources_stylesheets_nested_iframes.js] +[browser_resources_target_destroy.js] +[browser_resources_target_resources_race.js] +[browser_resources_target_switching.js] +[browser_resources_thread_states.js] +[browser_resources_unwatch_early.js] +[browser_resources_watch_unwatch_multiple.js] +[browser_resources_websocket.js] +skip-if = http3 # Bug 1829298 diff --git a/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js b/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js new file mode 100644 index 0000000000..1c6c776e64 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_browser_resources_console_messages.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CONSOLE_MESSAGE for the whole browser + +const TEST_URL = URL_ROOT_SSL + "early_console_document.html"; + +add_task(async function () { + // Enable Multiprocess Browser Toolbox. + await pushPref("devtools.browsertoolbox.scope", "everything"); + + const { client, resourceCommand, targetCommand } = + await initMultiProcessResourceCommand(); + + const d = Date.now(); + const CACHED_MESSAGE_TEXT = `cached-${d}`; + const LIVE_MESSAGE_TEXT = `live-${d}`; + + info( + "Log some messages *before* calling ResourceCommand.watchResources in order to " + + "assert the behavior of already existing messages." + ); + console.log(CACHED_MESSAGE_TEXT); + + info("Wait for existing browser mochitest log"); + const { onResource } = await resourceCommand.waitForNextResource( + resourceCommand.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: false, + predicate({ message }) { + return message.arguments[0] === CACHED_MESSAGE_TEXT; + }, + } + ); + const existingMsg = await onResource; + ok(existingMsg, "The existing log was retrieved"); + is( + existingMsg.isAlreadyExistingResource, + true, + "isAlreadyExistingResource is true for the existing message" + ); + + const { onResource: onMochitestRuntimeLog } = + await resourceCommand.waitForNextResource( + resourceCommand.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: false, + predicate({ message }) { + return message.arguments[0] === LIVE_MESSAGE_TEXT; + }, + } + ); + console.log(LIVE_MESSAGE_TEXT); + + info("Wait for runtime browser mochitest log"); + const runtimeLogResource = await onMochitestRuntimeLog; + ok(runtimeLogResource, "The runtime log was retrieved"); + is( + runtimeLogResource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false for the runtime message" + ); + + const { onResource: onEarlyLog } = await resourceCommand.waitForNextResource( + resourceCommand.TYPES.CONSOLE_MESSAGE, + { + ignoreExistingResources: true, + predicate({ message }) { + return message.arguments[0] === "early-page-log"; + }, + } + ); + await addTab(TEST_URL); + info("Wait for early page log"); + const earlyResource = await onEarlyLog; + ok(earlyResource, "The early page log was retrieved"); + is( + earlyResource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false for the early message" + ); + + targetCommand.destroy(); + await client.close(); +}); diff --git a/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js b/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js new file mode 100644 index 0000000000..44068cb141 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_clear_resources.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the clearResources function of the ResourceCommand + +add_task(async () => { + const tab = await addTab(`${URL_ROOT_SSL}empty.html`); + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Assert the initial no of resources"); + assertNoOfResources(resourceCommand, 0, 0); + + const onAvailable = () => {}; + const onUpdated = () => {}; + + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { onAvailable, onUpdated } + ); + + info("Log some messages"); + await logConsoleMessages(tab.linkedBrowser, ["log1", "log2", "log3"]); + + info("Trigger some network requests"); + const EXAMPLE_DOMAIN = "https://example.com/"; + await triggerNetworkRequests(tab.linkedBrowser, [ + `await fetch("${EXAMPLE_DOMAIN}/request1.html", { method: "GET" });`, + `await fetch("${EXAMPLE_DOMAIN}/request2.html", { method: "GET" });`, + ]); + + assertNoOfResources(resourceCommand, 3, 2); + + info("Clear the network event resources"); + await resourceCommand.clearResources([resourceCommand.TYPES.NETWORK_EVENT]); + assertNoOfResources(resourceCommand, 3, 0); + + info("Clear the console message resources"); + await resourceCommand.clearResources([resourceCommand.TYPES.CONSOLE_MESSAGE]); + assertNoOfResources(resourceCommand, 0, 0); + + resourceCommand.unwatchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.NETWORK_EVENT, + ], + { onAvailable, onUpdated, ignoreExistingResources: true } + ); + + targetCommand.destroy(); + await client.close(); +}); + +function assertNoOfResources( + resourceCommand, + expectedNoOfConsoleMessageResources, + expectedNoOfNetworkEventResources +) { + const actualNoOfConsoleMessageResources = resourceCommand.getAllResources( + resourceCommand.TYPES.CONSOLE_MESSAGE + ).length; + is( + actualNoOfConsoleMessageResources, + expectedNoOfConsoleMessageResources, + `There are ${actualNoOfConsoleMessageResources} console messages resources` + ); + + const actualNoOfNetworkEventResources = resourceCommand.getAllResources( + resourceCommand.TYPES.NETWORK_EVENT + ).length; + is( + actualNoOfNetworkEventResources, + expectedNoOfNetworkEventResources, + `There are ${actualNoOfNetworkEventResources} network event resources` + ); +} + +function logConsoleMessages(browser, messages) { + return SpecialPowers.spawn(browser, [messages], innerMessages => { + for (const message of innerMessages) { + content.console.log(message); + } + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_client_caching.js b/devtools/shared/commands/resource/tests/browser_resources_client_caching.js new file mode 100644 index 0000000000..a10c74e298 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_client_caching.js @@ -0,0 +1,376 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the cache mechanism of the ResourceCommand. + +const TEST_URI = "data:text/html;charset=utf-8,Cache Test"; + +add_task(async function () { + info("Test whether multiple listener can get same cached resources"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const messages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, messages); + + info("Register first listener"); + const cachedResources1 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + ok(areExistingResources, "All resources are already existing ones"); + cachedResources1.push(...resources); + }, + } + ); + + info("Register second listener"); + const cachedResources2 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + ok(areExistingResources, "All resources are already existing ones"); + cachedResources2.push(...resources); + }, + } + ); + + assertContents(cachedResources1, messages); + assertResources(cachedResources2, cachedResources1); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info( + "Test whether the cache is reflecting existing resources and additional resources" + ); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener to get all available resources"); + const availableResources = []; + // We first get notified about existing resources + let shouldBeExistingResources = true; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + is( + areExistingResources, + shouldBeExistingResources, + "areExistingResources flag is correct" + ); + availableResources.push(...resources); + }, + } + ); + // Then, we are notified about, new, live ones + shouldBeExistingResources = false; + + info("Add messages as additional resources"); + const additionalMessages = ["d", "e"]; + await logMessages(tab.linkedBrowser, additionalMessages); + + info("Wait until onAvailable is called expected times"); + const allMessages = [...existingMessages, ...additionalMessages]; + await waitUntil(() => availableResources.length === allMessages.length); + + info("Register second listener to get the cached resources"); + const cachedResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable(resources, { areExistingResources }) { + ok(areExistingResources, "All resources are already existing ones"); + cachedResources.push(...resources); + }, + } + ); + + assertContents(availableResources, allMessages); + assertResources(cachedResources, availableResources); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info("Test whether the cache is cleared when navigation"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener"); + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: () => {}, + } + ); + + info("Reload the page"); + await BrowserTestUtils.reloadTab(tab); + + info("Register second listener"); + const cachedResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + is(cachedResources.length, 0, "The cache in ResourceCommand is cleared"); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info("Test with multiple resource types"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Register first listener to get all available resources"); + const availableResources = []; + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.ERROR_MESSAGE, + ], + { + onAvailable: resources => availableResources.push(...resources), + } + ); + + info("Add messages as console message"); + const consoleMessages1 = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, consoleMessages1); + + info("Add message as error message"); + const errorMessages = ["document.doTheImpossible();"]; + await triggerErrors(tab.linkedBrowser, errorMessages); + + info("Add messages as console message again"); + const consoleMessages2 = ["d", "e"]; + await logMessages(tab.linkedBrowser, consoleMessages2); + + info("Wait until the getting all available resources"); + const totalResourceCount = + consoleMessages1.length + errorMessages.length + consoleMessages2.length; + await waitUntil(() => { + return availableResources.length === totalResourceCount; + }); + + info("Register listener to get the cached resources"); + const cachedResources = []; + await resourceCommand.watchResources( + [ + resourceCommand.TYPES.CONSOLE_MESSAGE, + resourceCommand.TYPES.ERROR_MESSAGE, + ], + { + onAvailable: resources => cachedResources.push(...resources), + } + ); + + assertResources(cachedResources, availableResources); + + targetCommand.destroy(); + await client.close(); +}); + +add_task(async function () { + info("Test multiple listeners with/without ignoreExistingResources"); + await testIgnoreExistingResources(true); + await testIgnoreExistingResources(false); +}); + +async function testIgnoreExistingResources(isFirstListenerIgnoreExisting) { + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Add messages as existing resources"); + const existingMessages = ["a", "b", "c"]; + await logMessages(tab.linkedBrowser, existingMessages); + + info("Register first listener"); + const cachedResources1 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources1.push(...resources), + ignoreExistingResources: isFirstListenerIgnoreExisting, + } + ); + + info("Register second listener"); + const cachedResources2 = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => cachedResources2.push(...resources), + ignoreExistingResources: !isFirstListenerIgnoreExisting, + } + ); + + const cachedResourcesWithFlag = isFirstListenerIgnoreExisting + ? cachedResources1 + : cachedResources2; + const cachedResourcesWithoutFlag = isFirstListenerIgnoreExisting + ? cachedResources2 + : cachedResources1; + + info("Check the existing resources both listeners got"); + assertContents(cachedResourcesWithFlag, []); + assertContents(cachedResourcesWithoutFlag, existingMessages); + + info("Add messages as additional resources"); + const additionalMessages = ["d", "e"]; + await logMessages(tab.linkedBrowser, additionalMessages); + + info("Wait until onAvailable is called expected times"); + await waitUntil( + () => cachedResourcesWithFlag.length === additionalMessages.length + ); + const allMessages = [...existingMessages, ...additionalMessages]; + await waitUntil( + () => cachedResourcesWithoutFlag.length === allMessages.length + ); + + info("Check the resources after adding messages"); + assertContents(cachedResourcesWithFlag, additionalMessages); + assertContents(cachedResourcesWithoutFlag, allMessages); + + targetCommand.destroy(); + await client.close(); +} + +add_task(async function () { + info("Test that onAvailable is not called with an empty resources array"); + + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Register first listener to get all available resources"); + const availableResources = []; + let onAvailableCallCount = 0; + const onAvailable = resources => { + ok( + !!resources.length, + "onAvailable is called with a non empty resources array" + ); + availableResources.push(...resources); + onAvailableCallCount++; + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + is(availableResources.length, 0, "availableResources array is empty"); + is(onAvailableCallCount, 0, "onAvailable was never called"); + + info("Add messages as console message"); + await logMessages(tab.linkedBrowser, ["expected message"]); + + await waitUntil(() => availableResources.length === 1); + is(availableResources.length, 1, "availableResources array has one item"); + is(onAvailableCallCount, 1, "onAvailable was called only once"); + is( + availableResources[0].message.arguments[0], + "expected message", + "onAvailable was called with the expected resource" + ); + + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); + targetCommand.destroy(); + await client.close(); +}); + +function assertContents(resources, expectedMessages) { + is( + resources.length, + expectedMessages.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < expectedMessages.length; i++) { + const resource = resources[i]; + const message = resource.message.arguments[0]; + const expectedMessage = expectedMessages[i]; + is(message, expectedMessage, `The ${i}th content is correct`); + } +} + +function assertResources(resources, expectedResources) { + is( + resources.length, + expectedResources.length, + "Number of the resources is correct" + ); + + for (let i = 0; i < resources.length; i++) { + const resource = resources[i]; + const expectedResource = expectedResources[i]; + ok(resource === expectedResource, `The ${i}th resource is correct`); + } +} + +function logMessages(browser, messages) { + return ContentTask.spawn(browser, { messages }, args => { + for (const message of args.messages) { + content.console.log(message); + } + }); +} + +async function triggerErrors(browser, errorScripts) { + for (const errorScript of errorScripts) { + await ContentTask.spawn(browser, errorScript, expr => { + const document = content.document; + const container = document.createElement("script"); + document.body.appendChild(container); + container.textContent = expr; + container.remove(); + }); + } +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages.js new file mode 100644 index 0000000000..6f02cd5a77 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages.js @@ -0,0 +1,623 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CONSOLE_MESSAGE +// +// Reproduces assertions from: devtools/shared/webconsole/test/chrome/test_cached_messages.html +// And now more. Once we remove the console actor's startListeners in favor of watcher class +// We could remove that other old test. + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html"; +const IFRAME_URL = URL_ROOT_ORG_SSL + "fission_iframe.html"; + +add_task(async function () { + info("Execute test in top level document"); + await testTabConsoleMessagesResources(false); + await testTabConsoleMessagesResourcesWithIgnoreExistingResources(false); + + info("Execute test in an iframe document, possibly remote with fission"); + await testTabConsoleMessagesResources(true); + await testTabConsoleMessagesResourcesWithIgnoreExistingResources(true); +}); + +async function testTabConsoleMessagesResources(executeInIframe) { + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info( + "Log some messages *before* calling ResourceCommand.watchResources in order to " + + "assert the behavior of already existing messages." + ); + await logExistingMessages(tab.linkedBrowser, executeInIframe); + + const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL; + + let runtimeDoneResolve; + const expectedExistingCalls = + getExpectedExistingConsoleCalls(targetDocumentUrl); + const expectedRuntimeCalls = + getExpectedRuntimeConsoleCalls(targetDocumentUrl); + const onRuntimeDone = new Promise(resolve => (runtimeDoneResolve = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + is( + resource.resourceType, + resourceCommand.TYPES.CONSOLE_MESSAGE, + "Received a message" + ); + ok(resource.message, "message is wrapped into a message attribute"); + const isCachedMessage = !!expectedExistingCalls.length; + const expected = ( + isCachedMessage ? expectedExistingCalls : expectedRuntimeCalls + ).shift(); + checkConsoleAPICall(resource.message, expected); + is( + resource.isAlreadyExistingResource, + isCachedMessage, + "isAlreadyExistingResource has the expected value" + ); + + if (!expectedRuntimeCalls.length) { + runtimeDoneResolve(); + } + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + is( + expectedExistingCalls.length, + 0, + "Got the expected number of existing messages" + ); + + info( + "Now log messages *after* the call to ResourceCommand.watchResources and after having received all existing messages" + ); + await logRuntimeMessages(tab.linkedBrowser, executeInIframe); + + info("Waiting for all runtime messages"); + await onRuntimeDone; + + is( + expectedRuntimeCalls.length, + 0, + "Got the expected number of runtime messages" + ); + + targetCommand.destroy(); + await client.close(); +} + +async function testTabConsoleMessagesResourcesWithIgnoreExistingResources( + executeInIframe +) { + info("Test ignoreExistingResources option for console messages"); + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info( + "Check whether onAvailable will not be called with existing console messages" + ); + await logExistingMessages(tab.linkedBrowser, executeInIframe); + + const availableResources = []; + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable: resources => availableResources.push(...resources), + ignoreExistingResources: true, + } + ); + is( + availableResources.length, + 0, + "onAvailable wasn't called for existing console messages" + ); + + info( + "Check whether onAvailable will be called with the future console messages" + ); + await logRuntimeMessages(tab.linkedBrowser, executeInIframe); + const targetDocumentUrl = executeInIframe ? IFRAME_URL : FISSION_TEST_URL; + const expectedRuntimeConsoleCalls = + getExpectedRuntimeConsoleCalls(targetDocumentUrl); + await waitUntil( + () => availableResources.length === expectedRuntimeConsoleCalls.length + ); + const expectedTargetFront = + executeInIframe && (isFissionEnabled() || isEveryFrameTargetEnabled()) + ? targetCommand + .getAllTargets([targetCommand.TYPES.FRAME]) + .find(target => target.url == IFRAME_URL) + : targetCommand.targetFront; + for (let i = 0; i < expectedRuntimeConsoleCalls.length; i++) { + const resource = availableResources[i]; + const { message, targetFront } = resource; + is( + targetFront, + expectedTargetFront, + "The targetFront property is the expected one" + ); + const expected = expectedRuntimeConsoleCalls[i]; + checkConsoleAPICall(message, expected); + is( + resource.isAlreadyExistingResource, + false, + "isAlreadyExistingResource is false since we're ignoring existing resources" + ); + } + + targetCommand.destroy(); + await client.close(); +} + +async function logExistingMessages(browser, executeInIframe) { + let browsingContext = browser.browsingContext; + if (executeInIframe) { + browsingContext = await SpecialPowers.spawn( + browser, + [], + function frameScript() { + return content.document.querySelector("iframe").browsingContext; + } + ); + } + return evalInBrowsingContext(browsingContext, function pageScript() { + console.log("foobarBaz-log", undefined); + console.info("foobarBaz-info", null); + console.warn("foobarBaz-warn", document.body); + }); +} + +/** + * Helper function similar to spawn, but instead of executing the script + * as a Frame Script, with privileges and including test harness in stacktraces, + * execute the script as a regular page script, without privileges and without any + * preceding stack. + * + * @param {BrowsingContext} The browsing context into which the script should be evaluated + * @param {Function|String} The JS to execute in the browsing context + * + * @return {Promise} Which resolves once the JS is done executing in the page + */ +function evalInBrowsingContext(browsingContext, script) { + return SpecialPowers.spawn(browsingContext, [String(script)], expr => { + const document = content.document; + const scriptEl = document.createElement("script"); + document.body.appendChild(scriptEl); + // Force the immediate execution of the stringified JS function passed in `expr` + scriptEl.textContent = "new " + expr; + scriptEl.remove(); + }); +} + +// For both existing and runtime messages, we execute console API +// from a page script evaluated via evalInBrowsingContext. +// Records here the function used to execute the script in the page. +const EXPECTED_FUNCTION_NAME = "pageScript"; + +const NUMBER_REGEX = /^\d+$/; +// timeStamp are the result of a number in microsecond divided by 1000. +// so we can't expect a precise number of decimals, or even if there would +// be decimals at all. +const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/; + +function getExpectedExistingConsoleCalls(documentFilename) { + const defaultProperties = { + filename: documentFilename, + columnNumber: NUMBER_REGEX, + lineNumber: NUMBER_REGEX, + timeStamp: FRACTIONAL_NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + chromeContext: undefined, + counter: undefined, + prefix: undefined, + private: undefined, + stacktrace: undefined, + styles: undefined, + timer: undefined, + }; + + return [ + { + ...defaultProperties, + level: "log", + arguments: ["foobarBaz-log", { type: "undefined" }], + }, + { + ...defaultProperties, + level: "info", + arguments: ["foobarBaz-info", { type: "null" }], + }, + { + ...defaultProperties, + level: "warn", + arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }], + }, + ]; +} + +const longString = new Array(DevToolsServer.LONG_STRING_LENGTH + 2).join("a"); +function getExpectedRuntimeConsoleCalls(documentFilename) { + const defaultStackFrames = [ + // This is the usage of "new " + expr from `evalInBrowsingContext` + { + filename: documentFilename, + lineNumber: NUMBER_REGEX, + columnNumber: NUMBER_REGEX, + }, + ]; + + const defaultProperties = { + filename: documentFilename, + columnNumber: NUMBER_REGEX, + lineNumber: NUMBER_REGEX, + timeStamp: FRACTIONAL_NUMBER_REGEX, + innerWindowID: NUMBER_REGEX, + chromeContext: undefined, + counter: undefined, + prefix: undefined, + private: undefined, + stacktrace: undefined, + styles: undefined, + timer: undefined, + }; + + return [ + { + ...defaultProperties, + level: "log", + arguments: ["foobarBaz-log", { type: "undefined" }], + }, + { + ...defaultProperties, + level: "log", + arguments: ["Float from not a number: NaN"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["Float from string: 1.200000"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["Float from number: 1.300000"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["BigInt 123 and 456"], + }, + { + ...defaultProperties, + level: "log", + arguments: ["message with ", "style"], + styles: ["color: blue;", "background: red; font-size: 2em;"], + }, + { + ...defaultProperties, + level: "info", + arguments: ["foobarBaz-info", { type: "null" }], + }, + { + ...defaultProperties, + level: "warn", + arguments: ["foobarBaz-warn", { type: "object", actor: /[a-z]/ }], + }, + { + ...defaultProperties, + level: "debug", + arguments: [{ type: "null" }], + }, + { + ...defaultProperties, + level: "trace", + stacktrace: [ + { + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + }, + ...defaultStackFrames, + ], + }, + { + ...defaultProperties, + level: "dir", + arguments: [ + { + type: "object", + actor: /[a-z]/, + class: "HTMLDocument", + }, + { + type: "object", + actor: /[a-z]/, + class: "Location", + }, + ], + }, + { + ...defaultProperties, + level: "log", + arguments: [ + "foo", + { + type: "longString", + initial: longString.substring( + 0, + DevToolsServer.LONG_STRING_INITIAL_LENGTH + ), + length: longString.length, + actor: /[a-z]/, + }, + ], + }, + { + ...defaultProperties, + level: "count", + arguments: ["myCounter"], + counter: { + count: 1, + label: "myCounter", + }, + }, + { + ...defaultProperties, + level: "count", + arguments: ["myCounter"], + counter: { + count: 2, + label: "myCounter", + }, + }, + { + ...defaultProperties, + level: "count", + arguments: ["default"], + counter: { + count: 1, + label: "default", + }, + }, + { + ...defaultProperties, + level: "countReset", + arguments: ["myCounter"], + counter: { + count: 0, + label: "myCounter", + }, + }, + { + ...defaultProperties, + level: "countReset", + arguments: ["unknownCounter"], + counter: { + error: "counterDoesntExist", + label: "unknownCounter", + }, + }, + { + ...defaultProperties, + level: "time", + arguments: ["myTimer"], + timer: { + name: "myTimer", + }, + }, + { + ...defaultProperties, + level: "time", + arguments: ["myTimer"], + timer: { + name: "myTimer", + error: "timerAlreadyExists", + }, + }, + { + ...defaultProperties, + level: "timeLog", + arguments: ["myTimer"], + timer: { + name: "myTimer", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "timeEnd", + arguments: ["myTimer"], + timer: { + name: "myTimer", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "time", + arguments: ["default"], + timer: { + name: "default", + }, + }, + { + ...defaultProperties, + level: "timeLog", + arguments: ["default"], + timer: { + name: "default", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "timeEnd", + arguments: ["default"], + timer: { + name: "default", + duration: NUMBER_REGEX, + }, + }, + { + ...defaultProperties, + level: "timeLog", + arguments: ["unknownTimer"], + timer: { + name: "unknownTimer", + error: "timerDoesntExist", + }, + }, + { + ...defaultProperties, + level: "timeEnd", + arguments: ["unknownTimer"], + timer: { + name: "unknownTimer", + error: "timerDoesntExist", + }, + }, + { + ...defaultProperties, + level: "error", + arguments: ["foobarBaz-asmjs-error", { type: "undefined" }], + + stacktrace: [ + { + filename: documentFilename, + functionName: "fromAsmJS", + }, + { + filename: documentFilename, + functionName: "inAsmJS2", + }, + { + filename: documentFilename, + functionName: "inAsmJS1", + }, + { + filename: documentFilename, + functionName: EXPECTED_FUNCTION_NAME, + }, + ...defaultStackFrames, + ], + }, + { + ...defaultProperties, + level: "log", + filename: + "chrome://mochitests/content/browser/devtools/shared/commands/resource/tests/browser_resources_console_messages.js", + arguments: [ + { + type: "object", + actor: /[a-z]/, + class: "Restricted", + }, + ], + chromeContext: true, + }, + ]; +} + +async function logRuntimeMessages(browser, executeInIframe) { + let browsingContext = browser.browsingContext; + if (executeInIframe) { + browsingContext = await SpecialPowers.spawn( + browser, + [], + function frameScript() { + return content.document.querySelector("iframe").browsingContext; + } + ); + } + // First inject LONG_STRING_LENGTH in global scope it order to easily use it after + await evalInBrowsingContext( + browsingContext, + `function () {window.LONG_STRING_LENGTH = ${DevToolsServer.LONG_STRING_LENGTH};}` + ); + await evalInBrowsingContext(browsingContext, function pageScript() { + const _longString = new Array(window.LONG_STRING_LENGTH + 2).join("a"); + + console.log("foobarBaz-log", undefined); + + console.log("Float from not a number: %f", "foo"); + console.log("Float from string: %f", "1.2"); + console.log("Float from number: %f", 1.3); + console.log("BigInt %d and %i", 123n, 456n); + console.log( + "%cmessage with %cstyle", + "color: blue;", + "background: red; font-size: 2em;" + ); + + console.info("foobarBaz-info", null); + console.warn("foobarBaz-warn", document.documentElement); + console.debug(null); + console.trace(); + console.dir(document, location); + console.log("foo", _longString); + + console.count("myCounter"); + console.count("myCounter"); + console.count(); + console.countReset("myCounter"); + // will cause warnings because unknownCounter doesn't exist + console.countReset("unknownCounter"); + + console.time("myTimer"); + // will cause warning because myTimer already exist + console.time("myTimer"); + console.timeLog("myTimer"); + console.timeEnd("myTimer"); + console.time(); + console.timeLog(); + console.timeEnd(); + // // will cause warnings because unknownTimer doesn't exist + console.timeLog("unknownTimer"); + console.timeEnd("unknownTimer"); + + function fromAsmJS() { + console.error("foobarBaz-asmjs-error", undefined); + } + + (function (global, foreign) { + "use asm"; + function inAsmJS2() { + foreign.fromAsmJS(); + } + function inAsmJS1() { + inAsmJS2(); + } + return inAsmJS1; + })(null, { fromAsmJS })(); + }); + await SpecialPowers.spawn(browsingContext, [], function frameScript() { + const sandbox = new Cu.Sandbox(null, { invisibleToDebugger: true }); + const sandboxObj = sandbox.eval("new Object"); + content.console.log(sandboxObj); + }); +} + +// Copied from devtools/shared/webconsole/test/chrome/common.js +function checkConsoleAPICall(call, expected) { + is( + call.arguments?.length || 0, + expected.arguments?.length || 0, + "number of arguments" + ); + + checkObject(call, expected); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js new file mode 100644 index 0000000000..1d476e9f52 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages_navigation.js @@ -0,0 +1,190 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the resource command API around CONSOLE_MESSAGE when navigating +// tab and inner iframes to distinct origin/processes. + +const TEST_URL = URL_ROOT_COM_SSL + "doc_console.html"; +const TEST_IFRAME_URL = URL_ROOT_ORG_SSL + "doc_console_iframe.html"; +const TEST_DOMAIN = "https://example.org"; +add_task(async function () { + const START_URL = "data:text/html;charset=utf-8,foo"; + const tab = await addTab(START_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + await testCrossProcessTabNavigation(tab.linkedBrowser, resourceCommand); + await testCrossProcessIframeNavigation(tab.linkedBrowser, resourceCommand); + + targetCommand.destroy(); + await client.close(); + BrowserTestUtils.removeTab(tab); +}); + +async function testCrossProcessTabNavigation(browser, resourceCommand) { + info( + "Navigate the top level document from data: URI to a https document including remote iframes" + ); + + let doneResolve; + const messages = []; + const onConsoleLogsComplete = new Promise(resolve => (doneResolve = resolve)); + + const onAvailable = resources => { + messages.push(...resources); + if (messages.length == 2) { + doneResolve(); + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { onAvailable } + ); + + const onLoaded = BrowserTestUtils.browserLoaded(browser); + await BrowserTestUtils.loadURIString(browser, TEST_URL); + await onLoaded; + + info("Wait for log message"); + await onConsoleLogsComplete; + + // messages are coming from different targets so the order isn't guaranteed + const topLevelMessageResource = messages.find(resource => + resource.message.filename.startsWith(URL_ROOT_COM_SSL) + ); + const iframeMessage = messages.find(resource => + resource.message.filename.startsWith("data:") + ); + + assertConsoleMessage(resourceCommand, topLevelMessageResource, { + targetFront: resourceCommand.targetCommand.targetFront, + messageText: "top-level document log", + }); + assertConsoleMessage(resourceCommand, iframeMessage, { + targetFront: isEveryFrameTargetEnabled + ? resourceCommand.targetCommand + .getAllTargets([resourceCommand.targetCommand.TYPES.FRAME]) + .find(t => t.url.startsWith("data:")) + : resourceCommand.targetCommand.targetFront, + messageText: "data url data log", + }); + + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); +} + +async function testCrossProcessIframeNavigation(browser, resourceCommand) { + info("Navigate an inner iframe from data: URI to a https remote URL"); + + let doneResolve; + const messages = []; + const onConsoleLogsComplete = new Promise(resolve => (doneResolve = resolve)); + + const onAvailable = resources => { + messages.push( + ...resources.filter( + r => !r.message.arguments[0].startsWith("[WORKER] started") + ) + ); + if (messages.length == 3) { + doneResolve(); + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + + // messages are coming from different targets so the order isn't guaranteed + const topLevelMessageResource = messages.find(resource => + resource.message.arguments[0].startsWith("top-level") + ); + const dataUrlMessageResource = messages.find(resource => + resource.message.arguments[0].startsWith("data url") + ); + + // Assert cached messages from the previous top document + assertConsoleMessage(resourceCommand, topLevelMessageResource, { + messageText: "top-level document log", + }); + assertConsoleMessage(resourceCommand, dataUrlMessageResource, { + messageText: "data url data log", + }); + + // Navigate the iframe to another origin/process + await SpecialPowers.spawn(browser, [TEST_IFRAME_URL], function (iframeUrl) { + const iframe = content.document.querySelector("iframe"); + iframe.src = iframeUrl; + }); + + info("Wait for log message"); + await onConsoleLogsComplete; + + // iframeTarget will be different if Fission is on or off + const iframeTarget = await getIframeTargetFront( + resourceCommand.targetCommand + ); + + const iframeMessageResource = messages.find(resource => + resource.message.arguments[0].endsWith("iframe log") + ); + assertConsoleMessage(resourceCommand, iframeMessageResource, { + messageText: `${TEST_DOMAIN} iframe log`, + targetFront: iframeTarget, + }); + + resourceCommand.unwatchResources([resourceCommand.TYPES.CONSOLE_MESSAGE], { + onAvailable, + }); +} + +function assertConsoleMessage(resourceCommand, messageResource, expected) { + is( + messageResource.resourceType, + resourceCommand.TYPES.CONSOLE_MESSAGE, + "Resource is a console message" + ); + ok(messageResource.message, "message is wrapped into a message attribute"); + if (expected.targetFront) { + is( + messageResource.targetFront, + expected.targetFront, + "Message has the correct target front" + ); + } + is( + messageResource.message.arguments[0], + expected.messageText, + "The correct type of message" + ); +} + +async function getIframeTargetFront(targetCommand) { + // If Fission/EFT is enabled, the iframe will have a dedicated target, + // otherwise it will be debuggable via the top level target. + if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) { + return targetCommand.targetFront; + } + const frameTargets = targetCommand.getAllTargets([targetCommand.TYPES.FRAME]); + const browsingContextID = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + return content.document.querySelector("iframe").browsingContext.id; + } + ); + const iframeTarget = frameTargets.find(target => { + return target.browsingContextID == browsingContextID; + }); + ok(iframeTarget, "Found the iframe target front"); + return iframeTarget; +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js b/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js new file mode 100644 index 0000000000..4b10f1d2e4 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_console_messages_workers.js @@ -0,0 +1,257 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CONSOLE_MESSAGE in workers + +const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document_workers.html"; +const WORKER_FILE = "test_worker.js"; +const IFRAME_FILE = `${URL_ROOT_ORG_SSL}fission_iframe_workers.html`; + +add_task(async function () { + // Set the following pref to false as it's the one that enables direct connection + // to the worker targets + await pushPref("dom.worker.console.dispatch_events_to_main_thread", false); + + const tab = await addTab(FISSION_TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab, + { listenForWorkers: true } + ); + + info("Wait for the workers (from the main page and the iframe) to be ready"); + const targets = []; + await new Promise(resolve => { + const onAvailable = async ({ targetFront }) => { + targets.push(targetFront); + if (targets.length === 2) { + resolve(); + } + }; + targetCommand.watchTargets({ + types: [targetCommand.TYPES.WORKER], + onAvailable, + }); + }); + + // The worker logs a message right when it starts, containing its location, so we can + // assert that we get the logs from the worker spawned in the content page and from the + // worker spawned in the iframe. + info("Check that we receive the cached messages"); + + const resources = []; + const onAvailable = innerResources => { + for (const resource of innerResources) { + // Ignore resources from non worker targets + if (!resource.targetFront.isWorkerTarget) { + continue; + } + + resources.push(resource); + } + }; + + await resourceCommand.watchResources( + [resourceCommand.TYPES.CONSOLE_MESSAGE], + { + onAvailable, + } + ); + + is(resources.length, 2, "Got the expected number of existing messages"); + const startLogFromWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker` + ); + const startLogFromWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe` + ); + + checkStartWorkerLogMessage(startLogFromWorkerInMainPage, { + expectedUrl: `${URL_ROOT_SSL}${WORKER_FILE}#simple-worker`, + isAlreadyExistingResource: true, + }); + checkStartWorkerLogMessage(startLogFromWorkerInIframe, { + expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-iframe`, + isAlreadyExistingResource: true, + }); + let messageCount = resources.length; + + info( + "Now log messages *after* the call to ResourceCommand.watchResources and after having received all existing messages" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.wrappedJSObject.logMessageInWorker("live message from main page"); + + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [], () => { + content.wrappedJSObject.logMessageInWorker("live message from iframe"); + }); + }); + + // Wait until the 2 new logs are available + await waitUntil(() => resources.length === messageCount + 2); + const liveMessageFromWorkerInMainPage = resources.find( + ({ message }) => message.arguments[1] === "live message from main page" + ); + const liveMessageFromWorkerInIframe = resources.find( + ({ message }) => message.arguments[1] === "live message from iframe" + ); + + checkLogInWorkerMessage( + liveMessageFromWorkerInMainPage, + "live message from main page" + ); + + checkLogInWorkerMessage( + liveMessageFromWorkerInIframe, + "live message from iframe" + ); + + // update the current number of resources received + messageCount = resources.length; + + info("Now spawn new workers and log messages in main page and iframe"); + await SpecialPowers.spawn( + tab.linkedBrowser, + [WORKER_FILE], + async workerUrl => { + const spawnedWorker = new content.Worker(`${workerUrl}#spawned-worker`); + spawnedWorker.postMessage({ + type: "log-in-worker", + message: "live message in spawned worker from main page", + }); + + const iframe = content.document.querySelector("iframe"); + SpecialPowers.spawn(iframe, [workerUrl], async innerWorkerUrl => { + const spawnedWorkerInIframe = new content.Worker( + `${innerWorkerUrl}#spawned-worker-in-iframe` + ); + spawnedWorkerInIframe.postMessage({ + type: "log-in-worker", + message: "live message in spawned worker from iframe", + }); + }); + } + ); + + info( + "Wait until the 4 new logs are available (the ones logged at worker creation + the ones from postMessage" + ); + await waitUntil( + () => resources.length === messageCount + 4, + `Couldn't get the expected number of resources (expected ${ + messageCount + 4 + }, got ${resources.length})` + ); + const startLogFromSpawnedWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker` + ); + const startLogFromSpawnedWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === + `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe` + ); + const liveMessageFromSpawnedWorkerInMainPage = resources.find( + ({ message }) => + message.arguments[1] === "live message in spawned worker from main page" + ); + const liveMessageFromSpawnedWorkerInIframe = resources.find( + ({ message }) => + message.arguments[1] === "live message in spawned worker from iframe" + ); + + checkStartWorkerLogMessage(startLogFromSpawnedWorkerInMainPage, { + expectedUrl: `${URL_ROOT_SSL}${WORKER_FILE}#spawned-worker`, + }); + checkStartWorkerLogMessage(startLogFromSpawnedWorkerInIframe, { + expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#spawned-worker-in-iframe`, + }); + checkLogInWorkerMessage( + liveMessageFromSpawnedWorkerInMainPage, + "live message in spawned worker from main page" + ); + checkLogInWorkerMessage( + liveMessageFromSpawnedWorkerInIframe, + "live message in spawned worker from iframe" + ); + // update the current number of resources received + messageCount = resources.length; + + info( + "Add a remote iframe on the same origin we already have an iframe and check we get the messages" + ); + await SpecialPowers.spawn( + tab.linkedBrowser, + [IFRAME_FILE], + async iframeUrl => { + const iframe = content.document.createElement("iframe"); + iframe.src = `${iframeUrl}?hashSuffix=in-second-iframe`; + content.document.body.append(iframe); + } + ); + + info("Wait until the new log is available"); + await waitUntil( + () => resources.length === messageCount + 1, + `Couldn't get the expected number of resources (expected ${ + messageCount + 1 + }, got ${resources.length})` + ); + const startLogFromWorkerInSecondIframe = resources[resources.length - 1]; + checkStartWorkerLogMessage(startLogFromWorkerInSecondIframe, { + expectedUrl: `${URL_ROOT_ORG_SSL}${WORKER_FILE}#simple-worker-in-second-iframe`, + }); + + targetCommand.destroy(); + await client.close(); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +}); + +function checkStartWorkerLogMessage( + resource, + { expectedUrl, isAlreadyExistingResource = false } +) { + const { message } = resource; + const [firstArg, secondArg, thirdArg] = message.arguments; + is(firstArg, "[WORKER] started", "Got the expected first argument"); + is(secondArg, expectedUrl, "expected url was logged"); + is( + thirdArg?._grip?.class, + "DedicatedWorkerGlobalScope", + "the global scope was logged as expected" + ); + is( + resource.isAlreadyExistingResource, + isAlreadyExistingResource, + "Resource has expected value for isAlreadyExistingResource" + ); +} + +function checkLogInWorkerMessage(resource, expectedMessage) { + const { message } = resource; + const [firstArg, secondArg, thirdArg] = message.arguments; + is(firstArg, "[WORKER]", "Got the expected first argument"); + is(secondArg, expectedMessage, "expected message was logged"); + is( + thirdArg?._grip?.class, + "MessageEvent", + "the message event object was logged as expected" + ); + is( + resource.isAlreadyExistingResource, + false, + "Resource has expected value for isAlreadyExistingResource" + ); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_css_changes.js b/devtools/shared/commands/resource/tests/browser_resources_css_changes.js new file mode 100644 index 0000000000..22b11a8186 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_css_changes.js @@ -0,0 +1,151 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CSS_CHANGE. + +add_task(async function () { + // Open a test tab + const tab = await addTab( + "data:text/html,CSS Changes" + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + // CSS_CHANGE watcher doesn't record modification made before watching, + // so we have to start watching before doing any DOM mutation. + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], { + onAvailable: () => {}, + }); + + const { walker } = await targetCommand.targetFront.getFront("inspector"); + const nodeList = await walker.querySelectorAll(walker.rootNode, "body"); + const body = (await nodeList.items())[0]; + const style = ( + await body.inspectorFront.pageStyle.getApplied(body, { + skipPseudo: false, + }) + )[0]; + + info( + "Check whether ResourceCommand catches CSS change that fired before starting to watch" + ); + await setProperty(style.rule, 0, "color", "black"); + + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], { + onAvailable: resources => availableResources.push(...resources), + }); + assertResource( + availableResources[0], + { index: 0, property: "color", value: "black" }, + { index: 0, property: "color", value: "lime" } + ); + + info( + "Check whether ResourceCommand catches CSS changes after the property was renamed and updated" + ); + + // RuleRewriter:apply will not support a simultaneous rename + setProperty. + // Doing so would send inconsistent arguments to StyleRuleActor:setRuleText, + // the CSS text for the rule will not match the list of modifications, which + // would desynchronize the Changes view. Thankfully this scenario should not + // happen when using the UI to update the rules. + await renameProperty(style.rule, 0, "color", "background-color"); + await waitUntil(() => availableResources.length === 2); + assertResource( + availableResources[1], + { index: 0, property: "background-color", value: "black" }, + { index: 0, property: "color", value: "black" } + ); + + await setProperty(style.rule, 0, "background-color", "pink"); + await waitUntil(() => availableResources.length === 3); + assertResource( + availableResources[2], + { index: 0, property: "background-color", value: "pink" }, + { index: 0, property: "background-color", value: "black" } + ); + + info("Check whether ResourceCommand catches CSS change of disabling"); + await setPropertyEnabled(style.rule, 0, "background-color", false); + await waitUntil(() => availableResources.length === 4); + assertResource(availableResources[3], null, { + index: 0, + property: "background-color", + value: "pink", + }); + + info("Check whether ResourceCommand catches CSS change of new property"); + await createProperty(style.rule, 1, "font-size", "100px"); + await waitUntil(() => availableResources.length === 5); + assertResource( + availableResources[4], + { index: 1, property: "font-size", value: "100px" }, + null + ); + + info("Check whether ResourceCommand sends all resources added in this test"); + const existingResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_CHANGE], { + onAvailable: resources => existingResources.push(...resources), + }); + await waitUntil(() => existingResources.length === 5); + is(availableResources[0], existingResources[0], "1st resource is correct"); + is(availableResources[1], existingResources[1], "2nd resource is correct"); + is(availableResources[2], existingResources[2], "3rd resource is correct"); + is(availableResources[3], existingResources[3], "4th resource is correct"); + is(availableResources[4], existingResources[4], "4th resource is correct"); + + targetCommand.destroy(); + await client.close(); +}); + +function assertResource(resource, expectedAddedChange, expectedRemovedChange) { + if (expectedAddedChange) { + is(resource.add.length, 1, "The number of added changes is correct"); + assertChange(resource.add[0], expectedAddedChange); + } else { + is(resource.add, null, "There is no added changes"); + } + + if (expectedRemovedChange) { + is(resource.remove.length, 1, "The number of removed changes is correct"); + assertChange(resource.remove[0], expectedRemovedChange); + } else { + is(resource.remove, null, "There is no removed changes"); + } +} + +function assertChange(change, expected) { + is(change.index, expected.index, "The index of change is correct"); + is(change.property, expected.property, "The property of change is correct"); + is(change.value, expected.value, "The value of change is correct"); +} + +async function setProperty(rule, index, property, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.setProperty(index, property, value, ""); + await modifications.apply(); +} + +async function renameProperty(rule, index, oldName, newName, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.renameProperty(index, oldName, newName); + await modifications.apply(); +} + +async function createProperty(rule, index, property, value) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.createProperty(index, property, value, "", true); + await modifications.apply(); +} + +async function setPropertyEnabled(rule, index, property, isEnabled) { + const modifications = rule.startModifyingProperties({ isKnown: true }); + modifications.setPropertyEnabled(index, property, isEnabled); + await modifications.apply(); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_css_messages.js b/devtools/shared/commands/resource/tests/browser_resources_css_messages.js new file mode 100644 index 0000000000..2146904bb0 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_css_messages.js @@ -0,0 +1,210 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API around CSS_MESSAGE +// Reproduces the CSS message assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html + +const { MESSAGE_CATEGORY } = require("resource://devtools/shared/constants.js"); + +// Create a simple server so we have a nice sourceName in the resources packets. +const httpServer = createTestHTTPServer(); +httpServer.registerPathHandler(`/test_css_messages.html`, (req, res) => { + res.setStatusLine(req.httpVersion, 200, "OK"); + res.write(` + Test CSS Messages`); +}); + +const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_css_messages.html`; + +add_task(async function () { + await testWatchingCssMessages(); + await testWatchingCachedCssMessages(); +}); + +async function testWatchingCssMessages() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // Open a test tab + const tab = await addTab(TEST_URI); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const receivedMessages = []; + const { onAvailable, onAllMessagesReceived } = setupOnAvailableFunction( + targetCommand, + receivedMessages, + false + ); + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_MESSAGE], { + onAvailable, + }); + + info( + "Now log CSS warning *after* the call to ResourceCommand.watchResources and after " + + "having received the existing message" + ); + // We need to wait for the first CSS Warning as it is not a cached message; when we + // start watching, the `cssErrorReportingEnabled` is checked on the target docShell, and + // if it is false, we re-parse the stylesheets to get the messages. + await BrowserTestUtils.waitForCondition(() => receivedMessages.length === 1); + + info("Trigger a CSS Warning"); + triggerCSSWarning(tab); + + info("Waiting for all expected CSS messages to be received"); + await onAllMessagesReceived; + ok(true, "All the expected CSS messages were received"); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +async function testWatchingCachedCssMessages() { + // Disable the preloaded process as it creates processes intermittently + // which forces the emission of RDP requests we aren't correctly waiting for. + await pushPref("dom.ipc.processPrelaunch.enabled", false); + + // Open a test tab + const tab = await addTab(TEST_URI); + + // By default, the CSS Parser does not emit warnings at all, for performance matter. + // Since we actually want the Parser to emit those messages _before_ we start listening + // for CSS messages, we need to set the cssErrorReportingEnabled flag on the docShell. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + content.docShell.cssErrorReportingEnabled = true; + }); + + // Setting the docShell flag only indicates to the Parser that from now on, it should + // emit warnings. But it does not automatically emit warnings for the existing CSS + // errors in the stylesheets. So here we reload the tab, which will make the Parser + // parse the stylesheets again, this time emitting warnings. + await reloadBrowser(); + // and trigger more CSS warnings + await triggerCSSWarning(tab); + + // At this point, all messages should be in the ConsoleService cache, and we can begin + // to watch and check that we do retrieve those messages. + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + const receivedMessages = []; + const { onAvailable } = setupOnAvailableFunction( + targetCommand, + receivedMessages, + true + ); + await resourceCommand.watchResources([resourceCommand.TYPES.CSS_MESSAGE], { + onAvailable, + }); + is(receivedMessages.length, 3, "Cached messages were retrieved as expected"); + + Services.console.reset(); + targetCommand.destroy(); + await client.close(); +} + +function setupOnAvailableFunction( + targetCommand, + receivedMessages, + isAlreadyExistingResource +) { + // timeStamp are the result of a number in microsecond divided by 1000. + // so we can't expect a precise number of decimals, or even if there would + // be decimals at all. + const FRACTIONAL_NUMBER_REGEX = /^\d+(\.\d{1,3})?$/; + + // The expected messages are the CSS warnings: + // - one for the rule in the style element + // - two for the JS modified style we're doing in the test. + const expectedMessages = [ + { + pageError: { + errorMessage: /Expected color but found ‘bloup’/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + }, + cssSelectors: "html", + isAlreadyExistingResource, + }, + { + pageError: { + errorMessage: /Error in parsing value for ‘width’/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + }, + isAlreadyExistingResource, + }, + { + pageError: { + errorMessage: /Error in parsing value for ‘height’/, + sourceName: /test_css_messages/, + category: MESSAGE_CATEGORY.CSS_PARSER, + timeStamp: FRACTIONAL_NUMBER_REGEX, + error: false, + warning: true, + }, + isAlreadyExistingResource, + }, + ]; + + let done; + const onAllMessagesReceived = new Promise(resolve => (done = resolve)); + const onAvailable = resources => { + for (const resource of resources) { + const { pageError } = resource; + + is( + resource.targetFront, + targetCommand.targetFront, + "The targetFront property is the expected one" + ); + + if (!pageError.sourceName.includes("test_css_messages")) { + info(`Ignore error from unknown source: "${pageError.sourceName}"`); + continue; + } + + const index = receivedMessages.length; + receivedMessages.push(resource); + + info( + `checking received css message #${index}: ${pageError.errorMessage}` + ); + ok(pageError, "The resource has a pageError attribute"); + checkObject(resource, expectedMessages[index]); + + if (receivedMessages.length == expectedMessages.length) { + done(); + } + } + }; + return { onAvailable, onAllMessagesReceived }; +} + +/** + * Sets invalid values for width and height on the document's body style attribute. + */ +function triggerCSSWarning(tab) { + return ContentTask.spawn(tab.linkedBrowser, null, function frameScript() { + content.document.body.style.width = "red"; + content.document.body.style.height = "blue"; + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_document_events.js b/devtools/shared/commands/resource/tests/browser_resources_document_events.js new file mode 100644 index 0000000000..2bd70b9272 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_document_events.js @@ -0,0 +1,711 @@ +/* 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.loadURIString(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.loadURIString(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.loadURIString(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) { + ok( + willNavigateEvent.time <= loadingEvent.time, + `Timestamp for dom-loading event is greater than will-navigate event (${willNavigateEvent.time} <= ${loadingEvent.time})` + ); + } + ok( + loadingEvent.time <= interactiveEvent.time, + `Timestamp for interactive event is greater than loading event (${loadingEvent.time} <= ${interactiveEvent.time})` + ); + ok( + 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..5ddc033663 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_getAllResources.js @@ -0,0 +1,124 @@ +/* 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]; + ok(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..bd84b81e09 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_network_events.js @@ -0,0 +1,316 @@ +/* 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); + ok( + 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); + ok( + 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.loadURIString(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..adf1e1ec52 --- /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.import( + "resource://gre/modules/NetUtil.jsm" + ); + 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.import( + "resource://gre/modules/NetUtil.jsm" + ); + 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..e4e7b57f13 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_reflows.js @@ -0,0 +1,111 @@ +/* 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"); + ok(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..440d6e6215 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_root_node.js @@ -0,0 +1,125 @@ +/* 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; + ok( + root1 !== root2, + "onAvailable has been called with a different node front after reload" + ); + + info("Navigate to another URL"); + rootNodePromise = new Promise(r => (rootNodeResolve = r)); + BrowserTestUtils.loadURIString(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..2495b2bb2a --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_sources.js @@ -0,0 +1,450 @@ +/* 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), + }); + + await BrowserTestUtils.loadURIString(tab.linkedBrowser, TEST_URL); + + // 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..d246155f21 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets.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 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: 3, + atRules: [ + { + conditionText: "all", + mediaText: "all", + matches: true, + line: 1, + column: 1, + }, + { + conditionText: "print", + mediaText: "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 testResourceAvailableFeature(); + await testResourceUpdateFeature(); + await testNestedResourceUpdateFeature(); +}); + +async function testResourceAvailableFeature() { + 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 = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + for (let i = 0; i < availableResources.length; i++) { + const availableResource = availableResources[i]; + // We can not expect the resources to always be forwarded in the same order. + // See intermittent Bug 1655016. + const expectedResource = findMatchingExpectedResource(availableResource); + ok(expectedResource, "Found a matching expected resource for the resource"); + await assertResource(availableResource, expectedResource); + } + + 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.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 + ); + + 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: resources => availableResources.push(...resources), + 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 = [ + { + conditionText: "screen", + mediaText: "screen", + matches: true, + }, + { + conditionText: "print", + mediaText: "print", + matches: false, + }, + ]; + + const updateCause = "updated-by-test"; + await styleSheetsFront.update( + resource.resourceId, + "@media screen { color: red; } @media print { color: green; } body { color: cyan; }", + false, + updateCause + ); + await waitUntil(() => updates.length === 4); + + assertUpdate(updates[1].update, { + resourceId: resource.resourceId, + updateType: "property-change", + }); + is( + updates[1].update.resourceUpdates.ruleCount, + 3, + "resourceUpdates is correct" + ); + is(updates[1].resource.ruleCount, 3, "cached resource is updated correctly"); + + assertUpdate(updates[2].update, { + resourceId: resource.resourceId, + updateType: "style-applied", + event: { + cause: updateCause, + }, + }); + is( + updates[2].update.resourceUpdates, + undefined, + "resourceUpdates is correct" + ); + + assertUpdate(updates[3].update, { + resourceId: resource.resourceId, + updateType: "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: resources => availableResources.push(...resources), + onUpdated: newUpdates => updates.push(...newUpdates), + }); + is( + availableResources.length, + EXISTING_RESOURCES.length, + "Length of existing resources is correct" + ); + + info("Apply new media query"); + // In order to avoid applying the media query (min-height: 400px). + 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) { color: red; }", + false + ); + await waitUntil(() => updates.length === 3); + 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", + }); + ok(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 = [ + { + conditionText: "(min-height: 400px)", + mediaText: "(min-height: 400px)", + matches: true, + }, + ]; + + assertAtRules(targetUpdate.resource.atRules, expectedAtRules); + + // Check the actual page. + const styleSheetResult = await getStyleSheetResult(tab); + is( + styleSheetResult.ruleCount, + 1, + "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]; + const ruleCount = stylesheet.cssRules.length; + + const atRules = []; + for (const rule of stylesheet.cssRules) { + if (!rule.media) { + continue; + } + + let matches = false; + try { + const mql = content.matchMedia(rule.media.mediaText); + matches = mql.matches; + } catch (e) { + // Ignored + } + + atRules.push({ + mediaText: rule.media.mediaText, + conditionText: rule.conditionText, + matches, + }); + } + + return { ruleCount, atRules }; + }); + + return result; +} + +function assertAtRules(atRules, expected) { + is(atRules.length, expected.length, "Length of the atRules is correct"); + + for (let i = 0; i < atRules.length; i++) { + is( + atRules[i].conditionText, + expected[i].conditionText, + "conditionText is correct" + ); + is(atRules[i].mediaText, expected[i].mediaText, "mediaText is correct"); + is(atRules[i].matches, expected[i].matches, "matches is correct"); + + if (expected[i].line !== undefined) { + is(atRules[i].line, expected[i].line, "line is correct"); + } + + if (expected[i].column !== undefined) { + is(atRules[i].column, expected[i].column, "column is correct"); + } + } +} + +async function assertResource(resource, expected) { + is( + resource.resourceType, + ResourceCommand.TYPES.STYLESHEET, + "Resource type is correct" + ); + const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); + const styleText = ( + await styleSheetsFront.getText(resource.resourceId) + ).str.trim(); + is(styleText, expected.styleText, "Style text is correct"); + is(resource.href, expected.href, "href is correct"); + is(resource.nodeHref, expected.nodeHref, "nodeHref is correct"); + is(resource.isNew, expected.isNew, "isNew is correct"); + is(resource.disabled, expected.disabled, "disabled is correct"); + is(resource.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 getResourceTimingCount(tab) { + return ContentTask.spawn(tab.linkedBrowser, [], () => { + return content.performance.getEntriesByType("resource").length; + }); +} diff --git a/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js new file mode 100644 index 0000000000..a6b06a7613 --- /dev/null +++ b/devtools/shared/commands/resource/tests/browser_resources_stylesheets_import.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the ResourceCommand API for imported STYLESHEET + iframe. + +const styleSheetText = ` +@import "${URL_ROOT_ORG_SSL}/style_document.css"; +body { background-color: tomato; }`; + +const IFRAME_URL = `https://example.org/document-builder.sjs?html=${encodeURIComponent(` + +

iframe

+`)}`; + +const TEST_URL = `https://example.org/document-builder.sjs?html= +

import stylesheet test

+ `; + +add_task(async function () { + info("Check resource available feature of the ResourceCommand"); + + const tab = await addTab(TEST_URL); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + info("Check whether ResourceCommand gets existing stylesheet"); + const availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => availableResources.push(...resources), + }); + + await waitFor(() => availableResources.length === 2); + ok(true, "We're getting the expected stylesheets"); + + const styleNodeStyleSheet = availableResources.find( + resource => resource.nodeHref + ); + const importedStyleSheet = availableResources.find( + resource => resource !== styleNodeStyleSheet + ); + + is( + await getStyleSheetText(styleNodeStyleSheet), + styleSheetText.trim(), + "Got expected text for the ` + + `` + + `` + + `` + + ``; + +const COOP_HEADERS = "Cross-Origin-Opener-Policy:same-origin"; +const TEST_URI_NEW_BROWSING_CONTEXT = + `${ORG_DOC_BUILDER}?headers=${COOP_HEADERS}` + + `&html=

top-level example.org

` + + ``; + +add_task(async function () { + info( + "Open a new tab and check that styleSheetChangeEventsEnabled is false by default" + ); + const tab = await addTab(TEST_URI); + + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + false, + `styleSheetChangeEventsEnabled is false at the beginning` + ); + + const { client, resourceCommand, targetCommand } = await initResourceCommand( + tab + ); + + let availableResources = []; + await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { + onAvailable: resources => { + availableResources.push(...resources); + }, + }); + + info("Wait for all the stylesheets resources of main document and iframes"); + await waitFor(() => availableResources.length === 5); + is(availableResources.length, 5, "Retrieved the expected stylesheets"); + + // the order of the resources is not guaranteed. + sortResourcesByExpectedOrder(availableResources); + await assertResource(availableResources[0], { + styleText: `.top-level-org{}`, + }); + await assertResource(availableResources[1], { + styleText: `.frame-org-1{}`, + }); + await assertResource(availableResources[2], { + styleText: `.frame-org-2{}`, + }); + await assertResource(availableResources[3], { + styleText: `.frame-com-1{}`, + }); + await assertResource(availableResources[4], { + styleText: `.frame-com-2{}`, + }); + + // clear availableResources so it's easier to test + availableResources = []; + + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + true, + `styleSheetChangeEventsEnabled is true after watching stylesheets` + ); + + info("Navigate a remote frame to a different page"); + const iframeNewUrl = + `https://example.com/document-builder.sjs?` + + `html=

example.com new bc

`; + await SpecialPowers.spawn(tab.linkedBrowser, [iframeNewUrl], url => { + const { browsingContext } = + content.document.querySelector("#remote-origin-2"); + return SpecialPowers.spawn(browsingContext, [url], innerUrl => { + content.document.location = innerUrl; + }); + }); + await waitFor(() => availableResources.length == 1); + ok(true, "We're notified about the iframe new document stylesheet"); + await assertResource(availableResources[0], { + styleText: `.frame-com-new-bc{}`, + }); + const iframeNewBrowsingContext = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("#remote-origin-2").browsingContext + ); + + is( + await getDocumentStyleSheetChangeEventsEnabled(iframeNewBrowsingContext), + true, + `styleSheetChangeEventsEnabled is still true after navigating the iframe` + ); + + // clear availableResources so it's easier to test + availableResources = []; + + info("Check that styleSheetChangeEventsEnabled persist after reloading"); + await reloadBrowser(); + + // ⚠️ When EFT is disabled, we're only getting the stylesheets for the top-level document + // and the remote frames; the same-origin iframes stylesheets are missing. + const expectedStylesheetResources = isEveryFrameTargetEnabled() ? 5 : 3; + info( + "Wait until we're notified about all the stylesheets (top-level document + iframe)" + ); + await waitFor( + () => availableResources.length === expectedStylesheetResources + ); + is( + availableResources.length, + expectedStylesheetResources, + "Retrieved the expected stylesheets after the page was reloaded" + ); + + // the order of the resources is not guaranteed. + sortResourcesByExpectedOrder(availableResources); + await assertResource(availableResources[0], { + styleText: `.top-level-org{}`, + }); + if (isEveryFrameTargetEnabled()) { + await assertResource(availableResources[1], { + styleText: `.frame-org-1{}`, + }); + await assertResource(availableResources[2], { + styleText: `.frame-org-2{}`, + }); + await assertResource(availableResources[3], { + styleText: `.frame-com-1{}`, + }); + await assertResource(availableResources[4], { + styleText: `.frame-com-new-bc{}`, + }); + } else { + await assertResource(availableResources[1], { + styleText: `.frame-com-1{}`, + }); + await assertResource(availableResources[2], { + styleText: `.frame-com-new-bc{}`, + }); + } + + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + true, + `styleSheetChangeEventsEnabled is still true on the top level document after reloading` + ); + + if (isEveryFrameTargetEnabled()) { + const bc = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + () => content.document.querySelector("#same-origin-1").browsingContext + ); + is( + await getDocumentStyleSheetChangeEventsEnabled(bc), + true, + `styleSheetChangeEventsEnabled is still true on the iframe after reloading` + ); + } + + // clear availableResources so it's easier to test + availableResources = []; + + info( + "Check that styleSheetChangeEventsEnabled persist when navigating to a page that creates a new browsing context" + ); + const previousBrowsingContextId = tab.linkedBrowser.browsingContext.id; + const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await BrowserTestUtils.loadURIString( + tab.linkedBrowser, + TEST_URI_NEW_BROWSING_CONTEXT + ); + await onLoaded; + + isnot( + tab.linkedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + info("Wait to get the stylesheet for the new document"); + await waitFor(() => availableResources.length === 1); + ok(true, "We received the stylesheet for the new document"); + await assertResource(availableResources[0], { + styleText: `.top-level-org-new-bc{}`, + }); + is( + await getDocumentStyleSheetChangeEventsEnabled(tab.linkedBrowser), + true, + `styleSheetChangeEventsEnabled is still true after navigating to a new browsing context` + ); + + targetCommand.destroy(); + await client.close(); +}); + +/** + * Returns the value of the browser/browsingContext document `styleSheetChangeEventsEnabled` + * property. + * + * @param {Browser|BrowsingContext} browserOrBrowsingContext: The browser element or a + * browsing context. + * @returns {Promise} + */ +function getDocumentStyleSheetChangeEventsEnabled(browserOrBrowsingContext) { + return SpecialPowers.spawn(browserOrBrowsingContext, [], () => { + return content.document.styleSheetChangeEventsEnabled; + }); +} + +/** + * Sort the passed array of stylesheet resources. + * + * Since the order of resources are not guaranteed, the + + + + + + + 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..2387c7e63b --- /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.ini"] diff --git a/devtools/shared/commands/script/script-command.js b/devtools/shared/commands/script/script-command.js new file mode 100644 index 0000000000..93917944d5 --- /dev/null +++ b/devtools/shared/commands/script/script-command.js @@ -0,0 +1,149 @@ +/* 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 + * + * @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, + }) + .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.ini b/devtools/shared/commands/script/tests/browser.ini new file mode 100644 index 0000000000..949c54f760 --- /dev/null +++ b/devtools/shared/commands/script/tests/browser.ini @@ -0,0 +1,11 @@ +[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..5d497983f0 --- /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.ini"] 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.ini b/devtools/shared/commands/target-configuration/tests/browser.ini new file mode 100644 index 0000000000..358934001e --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser.ini @@ -0,0 +1,17 @@ +[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_color_scheme.js] +skip-if = http3 # Bug 1829298 +[browser_target_configuration_command_custom_user_agent.js] +skip-if = http3 # Bug 1829298 +[browser_target_configuration_command_dppx.js] +skip-if = http3 # Bug 1829298 +[browser_target_configuration_command_touch_events.js] +skip-if = http3 # Bug 1829298 +[browser_target_configuration_command.js] 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..84ba79f46c --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/browser_target_configuration_command.js @@ -0,0 +1,79 @@ +/* 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" + ); + + 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..ccbfec93e6 --- /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.loadURIString( + 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..3ed0f8e142 --- /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.loadURIString( + 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..aa007e937b --- /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.loadURIString( + 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..55a0d198ce --- /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.loadURIString( + 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..a10b67c2b9 --- /dev/null +++ b/devtools/shared/commands/target-configuration/tests/target_configuration_test_doc.sjs @@ -0,0 +1,101 @@ +"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 + Cu.importGlobalProperties(["URLSearchParams"]); + 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..adaeb9def4 --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-serviceworkers-watcher.js @@ -0,0 +1,316 @@ +/* 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 }); + } + + _shouldDestroyTargetsOnNavigation() { + return !!this.targetCommand.destroyServiceWorkersOnNavigation; + } + + _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(); + const shouldDestroy = this._shouldDestroyTargetsOnNavigation(); + + for (const target of allServiceWorkerTargets) { + const isRegisteredBefore = + this.targetCommand.isTargetRegistered(target); + if (shouldDestroy && isRegisteredBefore) { + // Instruct the target command to notify about the worker target destruction + // but do not destroy the front as we want to keep using it. + // We will notify about it again via onTargetAvailable. + this.onTargetDestroyed(target, { shouldDestroyTargetFront: false }); + } + + // 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..0baa14757b --- /dev/null +++ b/devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js @@ -0,0 +1,238 @@ +/* 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 && + !workerTarget.url.startsWith( + "resource://gre/modules/subprocess/subprocess_worker" + ) + ); + } + + 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 and service workers targets are not + // destroyed on navigation, we don't want to remove the targets from targetsByProcess + if ( + !isTargetSwitching || + !this._isServiceWorkerWatcher || + this.targetCommand.destroyServiceWorkersOnNavigation + ) { + 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..c23940d7ed --- /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.ini"] 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..28e70c9f4b --- /dev/null +++ b/devtools/shared/commands/target/target-command.js @@ -0,0 +1,1173 @@ +/* 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.loadURIString(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..bfa297801a --- /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); + await BrowserTestUtils.loadURIString(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); + await BrowserTestUtils.loadURIString(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); + await BrowserTestUtils.loadURIString(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..d9e7ec65a9 --- /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, + }); + ok(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..f07a6aaac3 --- /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, + }); + ok(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..caf95f11c2 --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_service_workers_navigation.js @@ -0,0 +1,389 @@ +/* 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 + * - navigate to .org page + * - reload .org page + * - unregister .org worker + * - navigate back to .com page + * - unregister .com worker + * + * First we test this with destroyServiceWorkersOnNavigation = false. + * In this case we expect the following calls: + * - navigate to .com page + * - create target list + * - onAvailable should be called for the .com worker + * - navigate to .org page + * - onAvailable should be called for the .org worker + * - reload .org page + * - nothing should happen + * - unregister .org worker + * - onDestroyed should be called for the .org worker + * - navigate back to .com page + * - nothing should happen + * - unregister .com worker + * - onDestroyed should be called for the .com worker + */ +add_task(async function test_NavigationBetweenTwoDomains_NoDestroy() { + await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + const { hooks, commands, targetCommand } = await watchServiceWorkerTargets({ + tab, + destroyServiceWorkersOnNavigation: false, + }); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [COM_WORKER_URL], + }); + + info("Go to .org page, wait for onAvailable to be called"); + BrowserTestUtils.loadURIString(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(tab, ORG_PAGE_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.loadURIString(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(tab, COM_PAGE_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); +}); + +/** + * Same scenario as test_NavigationBetweenTwoDomains_NoDestroy, but this time + * with destroyServiceWorkersOnNavigation set to true. + * + * In this case we expect the following calls: + * - navigate to .com page + * - create target list + * - onAvailable should be called for the .com worker + * - navigate to .org page + * - onDestroyed should be called for the .com worker + * - onAvailable should be called for the .org worker + * - reload .org page + * - onDestroyed & onAvailable should be called for the .org worker + * - unregister .org worker + * - onDestroyed should be called for the .org worker + * - navigate back to .com page + * - onAvailable should be called for the .com worker + * - unregister .com worker + * - onDestroyed should be called for the .com worker + */ +add_task(async function test_NavigationBetweenTwoDomains_WithDestroy() { + await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + const { hooks, commands, targetCommand } = await watchServiceWorkerTargets({ + tab, + destroyServiceWorkersOnNavigation: true, + }); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [COM_WORKER_URL], + }); + + info("Go to .org page, wait for onAvailable to be called"); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, ORG_PAGE_URL); + await checkHooks(hooks, { + available: 2, + destroyed: 1, + targets: [ORG_WORKER_URL], + }); + + info("Reload .org page, onAvailable and onDestroyed should be called"); + gBrowser.reloadTab(gBrowser.selectedTab); + await checkHooks(hooks, { + available: 3, + destroyed: 2, + targets: [ORG_WORKER_URL], + }); + + info("Unregister .org service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, ORG_PAGE_URL); + await checkHooks(hooks, { available: 3, destroyed: 3, targets: [] }); + + info("Go back to page 1, wait for onDestroyed and onAvailable to be called"); + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, COM_PAGE_URL); + await checkHooks(hooks, { + available: 4, + destroyed: 3, + targets: [COM_WORKER_URL], + }); + + info("Unregister .com service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, COM_PAGE_URL); + await checkHooks(hooks, { available: 4, destroyed: 4, targets: [] }); + + // Stop listening to avoid worker related requests + 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 + * - unregister .org worker + * - navigate back to .com page + * - unregister .com worker + * + * The expected calls are the same whether destroyServiceWorkersOnNavigation is + * true or false. + * + * Expected calls: + * - navigate to .com page + * - navigate to .org page + * - create target list + * - onAvailable is called for the .org worker + * - unregister .org worker + * - onDestroyed is called for the .org worker + * - navigate back to .com page + * - onAvailable is called for the .com worker + * - unregister .com worker + * - onDestroyed is called for the .com worker + */ +add_task(async function test_NavigationToPageWithExistingWorker_NoDestroy() { + await testNavigationToPageWithExistingWorker({ + destroyServiceWorkersOnNavigation: false, + }); +}); + +add_task(async function test_NavigationToPageWithExistingWorker_WithDestroy() { + await testNavigationToPageWithExistingWorker({ + destroyServiceWorkersOnNavigation: true, + }); +}); + +async function testNavigationToPageWithExistingWorker({ + destroyServiceWorkersOnNavigation, +}) { + await setupServiceWorkerNavigationTest(); + + const tab = await addTab(COM_PAGE_URL); + + info("Wait until the service worker registration is registered"); + await waitForRegistrationReady(tab, COM_PAGE_URL); + + info("Navigate to another page"); + let onBrowserLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser + ); + BrowserTestUtils.loadURIString(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); + + const { hooks, commands, targetCommand } = await watchServiceWorkerTargets({ + tab, + destroyServiceWorkersOnNavigation, + }); + + // We expect onAvailable to have been called one time, for the only service + // worker target available in the test page. + await checkHooks(hooks, { + available: 1, + destroyed: 0, + targets: [ORG_WORKER_URL], + }); + + info("Unregister .org service worker and wait until onDestroyed is called."); + await unregisterServiceWorker(tab, ORG_PAGE_URL); + await checkHooks(hooks, { available: 1, destroyed: 1, targets: [] }); + + info("Go back .com page, wait for onAvailable to be called"); + onBrowserLoaded = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + BrowserTestUtils.loadURIString(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(tab, COM_PAGE_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); +} + +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({ + destroyServiceWorkersOnNavigation, + 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; + info( + "Set targetCommand.destroyServiceWorkersOnNavigation to " + + destroyServiceWorkersOnNavigation + ); + targetCommand.destroyServiceWorkersOnNavigation = + destroyServiceWorkersOnNavigation; + 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 }) => { + hooks.availableCount++; + hooks.targets.push(targetFront); + }; + + const onDestroyed = ({ targetFront }) => { + hooks.destroyedCount++; + hooks.targets.splice(hooks.targets.indexOf(targetFront), 1); + }; + + await targetCommand.watchTargets({ + types: [targetCommand.TYPES.SERVICE_WORKER], + onAvailable, + onDestroyed, + }); + + return { hooks, commands, targetCommand }; +} + +async function unregisterServiceWorker(tab, expectedPageUrl) { + await waitForRegistrationReady(tab, expectedPageUrl); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // registrationPromise is set by the test page. + const registration = await content.wrappedJSObject.registrationPromise; + registration.unregister(); + }); +} + +/** + * Wait until the expected URL is loaded and win.registration has resolved. + */ +async function waitForRegistrationReady(tab, expectedPageUrl) { + await asyncWaitUntil(() => + SpecialPowers.spawn(tab.linkedBrowser, [expectedPageUrl], function (_url) { + try { + const win = content.wrappedJSObject; + const isExpectedUrl = win.location.href === _url; + const hasRegistration = !!win.registrationPromise; + return isExpectedUrl && hasRegistration; + } catch (e) { + return false; + } + }) + ); +} + +/** + * Assert helper for the `hooks` object, updated by the onAvailable and + * onDestroyed callbacks. Assert that the callbacks have been called the + * expected number of times, with the expected targets. + */ +async function checkHooks(hooks, { available, destroyed, targets }) { + info(`Wait for availableCount=${available} and destroyedCount=${destroyed}`); + await waitUntil( + () => hooks.availableCount == available && hooks.destroyedCount == destroyed + ); + is(hooks.availableCount, available, "onAvailable was called as expected"); + is(hooks.destroyedCount, destroyed, "onDestroyed was called as expected"); + + is(hooks.targets.length, targets.length, "Expected number of targets"); + targets.forEach((url, i) => { + is(hooks.targets[i].url, url, `SW target ${i} has the expected url`); + }); +} diff --git a/devtools/shared/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..24710879ae --- /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.loadURIString( + 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..0f1ea45a08 --- /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.loadURIString( + 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..b61faf8f6e --- /dev/null +++ b/devtools/shared/commands/target/tests/browser_target_command_various_descriptors.js @@ -0,0 +1,283 @@ +/* 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); + ok( + 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); + await BrowserTestUtils.loadURIString(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.ini b/devtools/shared/commands/thread-configuration/tests/browser.ini new file mode 100644 index 0000000000..d918fce245 --- /dev/null +++ b/devtools/shared/commands/thread-configuration/tests/browser.ini @@ -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/compatibility/README.md b/devtools/shared/compatibility/README.md new file mode 100644 index 0000000000..b36dbfe10f --- /dev/null +++ b/devtools/shared/compatibility/README.md @@ -0,0 +1,27 @@ +# Compatibility Dataset + +## How to update the MDN compatibility data + +The Compatibility panel detects issues by comparing against official [MDN compatibility data](https://github.com/mdn/browser-compat-data). It uses a local snapshot of the dataset. This dataset needs to be manually synchronized periodically to `devtools/shared/compatibility/dataset` (ideally with every Firefox release). + +The subsets from the dataset required by the Compatibility panel are: + +- browsers: [https://github.com/mdn/browser-compat-data/tree/master/browsers](https://github.com/mdn/browser-compat-data/tree/master/browsers) +- css.properties: [https://github.com/mdn/browser-compat-data/tree/master/css](https://github.com/mdn/browser-compat-data/tree/master/css). + +In order to download up-to-date data, you need to run the following commands: + +- `cd devtools/shared/compatibility` +- `yarn install --no-lockfile` and select the latest package version for the `@mdn/browser-compat-data` package +- `yarn update` + +This should save the `css-properties.json` JSON file directly in `devtools/shared/compatibility/dataset/`. + +The browsers data are stored in a RemoteSettings collection, and updates are handled by a script in https://github.com/firefox-devtools/remote-settings-mdn-browser-compat-data . +The script is run every day in automation, and if the data are updated, the team should receive a data review email. + +To review the data update, you need to be connected to the Mozilla Corporate VPN (See https://mana.mozilla.org/wiki/display/SD/VPN), log into https://remote-settings.allizom.org/v1/admin/#/buckets/main/collections/devtools-compatibility-browsers/records (Using `OpenID Connect (LDAP)`) +Then run Firefox, and use the [RemoteSettings DevTools WebExtension](https://github.com/mozilla-extensions/remote-settings-devtools) to use the `Prod (preview)` environment and restart the browser. +Then open the compatibility panel and make sure that the updated browsers do appear in the `Settings` panel. + +Check that all tests still pass. It is possible that changes in the structure or contents of the latest dataset will cause tests to fail. If that is the case, fix the tests. **Do not manually change the contents or structure of the local dataset** because any changes will be overwritten by the next update from the official dataset. diff --git a/devtools/shared/compatibility/bin/update.js b/devtools/shared/compatibility/bin/update.js new file mode 100644 index 0000000000..f7fbfd2285 --- /dev/null +++ b/devtools/shared/compatibility/bin/update.js @@ -0,0 +1,197 @@ +/* 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 . */ + +// The Compatibility panel detects issues by comparing against official MDN compatibility data +// at https://github.com/mdn/browser-compat-data). It uses a local snapshot of the dataset. +// This dataset needs to be manually synchronized periodically + +// The subsets from the dataset required by the Compatibility panel are: +// * css.properties: https://github.com/mdn/browser-compat-data/tree/master/css + +// The MDN compatibility data is available as a node package ("@mdn/browser-compat-data"), +// which is used here to update `../dataset/css-properties.json`. + +/* eslint-disable mozilla/reject-relative-requires */ + +"use strict"; + +const compatData = require("@mdn/browser-compat-data"); +const { properties } = compatData.css; + +const { TARGET_BROWSER_ID } = require("../constants.js"); +const { getCompatTable } = require("../helpers.js"); + +// Flatten all CSS properties aliases here so we don't have to do it at runtime, +// which is costly. +flattenAliases(properties); +parseBrowserVersion(properties); +removeUnusedData(properties); +exportData(properties, "css-properties.json"); + +/** + * Builds a list of aliases between CSS properties, like flex and -webkit-flex, + * and mutates individual entries in the web compat data store for CSS properties to + * add their corresponding aliases. + */ +function flattenAliases(compatNode) { + for (const term in compatNode) { + if (term.startsWith("_")) { + // Ignore exploring if the term is _aliasOf or __compat. + continue; + } + + const compatTable = getCompatTable(compatNode, [term]); + if (compatTable) { + const aliases = findAliasesFrom(compatTable); + + for (const { alternative_name: name, prefix } of aliases) { + const alias = name || prefix + term; + compatNode[alias] = { _aliasOf: term }; + } + + if (aliases.length) { + // Make the term accessible as the alias. + compatNode[term]._aliasOf = term; + } + } + + // Flatten deeper node as well. + flattenAliases(compatNode[term]); + } +} + +function findAliasesFrom(compatTable) { + const aliases = []; + + for (const browser in compatTable.support) { + let supportStates = compatTable.support[browser] || []; + supportStates = Array.isArray(supportStates) + ? supportStates + : [supportStates]; + + for (const { alternative_name: name, prefix } of supportStates) { + if (!prefix && !name) { + continue; + } + + aliases.push({ alternative_name: name, prefix }); + } + } + + return aliases; +} + +function parseBrowserVersion(compatNode) { + for (const term in compatNode) { + if (term.startsWith("_")) { + // Ignore exploring if the term is _aliasOf or __compat. + continue; + } + + const compatTable = getCompatTable(compatNode, [term]); + if (compatTable?.support) { + for (const [browserId, supportItem] of Object.entries( + compatTable.support + )) { + // supportItem is an array when there are info for both prefixed and non-prefixed + // versions. If it's not an array in the original data, transform it into one + // since we'd have to do it in MDNCompatibility at runtime otherwise. + if (!Array.isArray(supportItem)) { + compatTable.support[browserId] = [supportItem]; + } + for (const item of compatTable.support[browserId]) { + replaceVersionsInBrowserSupport(item); + } + } + } + + // Handle deeper node as well. + parseBrowserVersion(compatNode[term]); + } +} + +function replaceVersionsInBrowserSupport(browserSupport) { + browserSupport.added = asFloatVersion(browserSupport.version_added); + browserSupport.removed = asFloatVersion(browserSupport.version_removed); +} + +function asFloatVersion(version) { + // `version` is not always a string (can be null, or a boolean) and in such case, + // we want to keep it that way. + if (typeof version !== "string") { + return version; + } + + if (version.startsWith("\u2264")) { + // MDN compatibility data started to use an expression like "≤66" for version. + // We just ignore the character here. + version = version.substring(1); + } + + return parseFloat(version); +} + +/** + * Remove all unused data from the file so it's smaller and faster to load + */ +function removeUnusedData(compatNode) { + for (const term in compatNode) { + if (term.startsWith("_")) { + // Ignore exploring if the term is _aliasOf or __compat. + continue; + } + + const compatTable = getCompatTable(compatNode, [term]); + + // source_file references the name of the file in the MDN compat data repo where the + // property is handled. We don't make use of it so we can remove it. + delete compatTable.source_file; + // Not used at the moment. Doesn't seem to have much information anyway + delete compatTable.description; + + if (compatTable?.support) { + for (const [browserId, supportItem] of Object.entries( + compatTable.support + )) { + // Remove any browser we won't handle + if (!TARGET_BROWSER_ID.includes(browserId)) { + delete compatTable.support[browserId]; + continue; + } + + // Remove `version_added` and `version_removed`, that are parsed in `replaceVersionsInBrowserSupport` + // and which we don't need anymore. + for (const item of supportItem) { + delete item.version_added; + delete item.version_removed; + // Those might be interesting, but we're not using them at the moment, so let's + // remove them as they can be quite lengthy + delete item.notes; + } + } + } + + // Handle deeper node as well. + removeUnusedData(compatNode[term]); + } +} + +function exportData(data, fileName) { + const fs = require("fs"); + const path = require("path"); + + const content = `${JSON.stringify(data)}`; + + fs.writeFile( + path.resolve(__dirname, "../dataset", fileName), + content, + err => { + if (err) { + console.error(err); + } else { + console.log(`${fileName} downloaded`); + } + } + ); +} diff --git a/devtools/shared/compatibility/compatibility-user-settings.js b/devtools/shared/compatibility/compatibility-user-settings.js new file mode 100644 index 0000000000..cfd0552968 --- /dev/null +++ b/devtools/shared/compatibility/compatibility-user-settings.js @@ -0,0 +1,127 @@ +/* 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 { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +loader.lazyRequireGetter( + this, + ["TARGET_BROWSER_ID", "TARGET_BROWSER_STATUS", "TARGET_BROWSER_PREF"], + "resource://devtools/shared/compatibility/constants.js", + true +); + +/** + * Returns the full list of browsers in the RemoteSetting devtools-compatibility-browsers + * collection (which is a flat copy of MDN compat data), sorted by browser and version. + * + * @returns Promise> : Objects in the array have the following shape: + * - {string} id: The browser id (e.g. `firefox`,`safari_ios`). Should be one of TARGET_BROWSER_ID + * - {string} name: The browser display name (e.g. `Firefox`,`Safari for IOS`, …) + * - {string} version: The browser version (e.g. `99`,`15.3`, `1.0.4`, …) + * - {string} status: The browser status (e.g. `current`,`beta`, …). Should be one of TARGET_BROWSER_STATUS + */ +async function getBrowsersList() { + const records = await RemoteSettings("devtools-compatibility-browsers", { + filterFunc: record => { + if ( + !TARGET_BROWSER_ID.includes(record.browserid) || + !TARGET_BROWSER_STATUS.includes(record.status) + ) { + return null; + } + return { + id: record.browserid, + name: record.name, + version: record.version, + status: record.status, + }; + }, + }).get(); + + const numericCollator = new Intl.Collator([], { numeric: true }); + records.sort((a, b) => { + if (a.id == b.id) { + return numericCollator.compare(a.version, b.version); + } + return a.id > b.id ? 1 : -1; + }); + + // MDN compat data might have browser data that have the same id and status. + // e.g. https://github.com/mdn/browser-compat-data/commit/53453400ecb2a85e7750d99e2e0a1611648d1d56#diff-31a16f09157f13354db27821261604aa + // In this case, only keep the newer version to keep uniqueness by id and status. + // This needs to be done after sorting since we rely on the order of the records. + return records.filter((record, index, arr) => { + const nextRecord = arr[index + 1]; + // If the next record in the array is the same browser and has the same status, filter + // out this one since it's a lower version. + if ( + nextRecord && + record.id === nextRecord.id && + record.status === nextRecord.status + ) { + return false; + } + + return true; + }); +} + +/** + * Returns the list of browsers for which we should check compatibility issues. + * + * @returns Promise> : Objects in the array have the following shape: + * - {string} id: The browser id (e.g. `firefox`,`safari_ios`). Should be one of TARGET_BROWSER_ID + * - {string} name: The browser display name (e.g. `Firefox`,`Safari for IOS`, …) + * - {string} version: The browser version (e.g. `99`,`15.3`, `1.0.4`, …) + * - {string} status: The browser status (e.g. `current`,`beta`, …). Should be one of TARGET_BROWSER_STATUS + */ +async function getTargetBrowsers() { + const targetsString = Services.prefs.getCharPref(TARGET_BROWSER_PREF, ""); + const browsers = await getBrowsersList(); + + // If not value are stored in the pref, it means the user did not chose specific browsers, + // so we need to return the full list. + if (!targetsString) { + return browsers; + } + + const selectedBrowsersAndStatuses = JSON.parse(targetsString); + return browsers.filter( + browser => + !!selectedBrowsersAndStatuses.find( + ({ id, status }) => browser.id == id && browser.status == status + ) + ); +} + +/** + * Store the list of browser id and status that should be used for checking compatibility + * issues. + * + * @param {Object[]} browsers + * @param {string} browsers[].id: The browser id. Should be one of TARGET_BROWSER_ID + * @param {string} browsers[].status: The browser status. Should be one of TARGET_BROWSER_STATUS + */ +function setTargetBrowsers(browsers) { + Services.prefs.setCharPref( + TARGET_BROWSER_PREF, + JSON.stringify( + // Only store the id and the status + browsers.map(browser => ({ + id: browser.id, + status: browser.status, + })) + ) + ); +} + +module.exports = { + getBrowsersList, + getTargetBrowsers, + setTargetBrowsers, +}; diff --git a/devtools/shared/compatibility/constants.js b/devtools/shared/compatibility/constants.js new file mode 100644 index 0000000000..472b7728aa --- /dev/null +++ b/devtools/shared/compatibility/constants.js @@ -0,0 +1,27 @@ +/* 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"; + +// This file might be required from a node script (./bin/update.js), so don't use +// Chrome API here. + +const TARGET_BROWSER_ID = [ + "firefox", + "firefox_android", + "chrome", + "chrome_android", + "safari", + "safari_ios", + "edge", + "ie", +]; +const TARGET_BROWSER_STATUS = ["esr", "current", "beta", "nightly"]; +const TARGET_BROWSER_PREF = "devtools.inspector.compatibility.target-browsers"; + +module.exports = { + TARGET_BROWSER_ID, + TARGET_BROWSER_PREF, + TARGET_BROWSER_STATUS, +}; diff --git a/devtools/shared/compatibility/dataset/css-properties.json b/devtools/shared/compatibility/dataset/css-properties.json new file mode 100644 index 0000000000..f1e48d91e4 --- /dev/null +++ b/devtools/shared/compatibility/dataset/css-properties.json @@ -0,0 +1 @@ +{"-moz-float-edge":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-moz-float-edge","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"-moz-force-broken-image-icon":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-moz-force-broken-image-icon","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"-moz-image-region":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-moz-image-region","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":1,"removed":112}],"firefox_android":[{"added":4,"removed":112}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"-moz-orient":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-moz-orient","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":6}],"firefox_android":[{"added":6}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"auto":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":21,"removed":40}],"firefox_android":[{"added":21,"removed":40}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"inline_and_block":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":40}],"firefox_android":[{"added":40}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"-moz-user-focus":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-moz-user-focus","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"-moz-user-input":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-moz-user-input","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"auto":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"disabled":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":1,"removed":60}],"firefox_android":[{"added":4,"removed":60}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"enabled":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":1,"removed":60}],"firefox_android":[{"added":4,"removed":60}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"none":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"-webkit-border-before":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-border-before","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":8}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"-webkit-box-reflect":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-box-reflect","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":4}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}}},"-webkit-line-clamp":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-line-clamp","spec_url":"https://drafts.csswg.org/css-overflow-4/#propdef--webkit-line-clamp","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":17}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"-webkit-mask-attachment":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-mask-attachment","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":1,"removed":24}],"chrome_android":[{"added":18,"removed":25}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":4,"removed":7}],"safari_ios":[{"added":3.2,"removed":7}]}}},"-webkit-mask-box-image":{"_aliasOf":"mask-border"},"-webkit-mask-composite":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-mask-composite","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":18}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":3.1}],"safari_ios":[{"added":2}]}}},"-webkit-mask-position-x":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-mask-position-x","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":18}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":false}],"safari":[{"added":3.1}],"safari_ios":[{"added":2}]}}},"-webkit-mask-position-y":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-mask-position-y","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":18}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":false}],"safari":[{"added":3.1}],"safari_ios":[{"added":2}]}}},"-webkit-mask-repeat-x":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-mask-repeat-x","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":3}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5,"removed":15}],"safari_ios":[{"added":5,"removed":15}]}}},"-webkit-mask-repeat-y":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-mask-repeat-y","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":3}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5,"removed":15}],"safari_ios":[{"added":5,"removed":15}]}}},"-webkit-overflow-scrolling":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-overflow-scrolling","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":5,"removed":13}]}}},"-webkit-tap-highlight-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-tap-highlight-color","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":16}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":4}]}}},"-webkit-text-fill-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-text-fill-color","spec_url":"https://compat.spec.whatwg.org/#the-webkit-text-fill-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":2}]}}},"-webkit-text-security":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-text-security","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":114}],"firefox_android":[{"added":114}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"-webkit-text-stroke":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-text-stroke","spec_url":"https://compat.spec.whatwg.org/#the-webkit-text-stroke","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4}],"chrome_android":[{"added":18}],"edge":[{"added":15}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":2}]}}},"-webkit-text-stroke-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-text-stroke-color","spec_url":"https://compat.spec.whatwg.org/#the-webkit-text-stroke-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":15}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":2}]}}},"-webkit-text-stroke-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-text-stroke-width","spec_url":"https://compat.spec.whatwg.org/#the-webkit-text-stroke-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":15}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":2}]}}},"-webkit-touch-callout":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-webkit-touch-callout","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":2}]}}},"accent-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/accent-color","spec_url":"https://drafts.csswg.org/css-ui/#widget-accent","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":93}],"chrome_android":[{"added":93}],"edge":[{"added":93}],"firefox":[{"added":92}],"firefox_android":[{"added":92}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"align-content":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/align-content","spec_url":["https://drafts.csswg.org/css-align/#align-justify-content","https://drafts.csswg.org/css-flexbox/#align-content-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":21}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":28},{"prefix":"-webkit-","added":49}],"firefox_android":[{"added":28},{"prefix":"-webkit-","added":49}],"ie":[{"added":11}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"flex_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-align/#align-justify-content","https://drafts.csswg.org/css-flexbox/#align-content-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":21}],"chrome_android":[{"added":25}],"edge":[{"added":12}],"firefox":[{"added":28}],"firefox_android":[{"added":28}],"ie":[{"added":11}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}},"baseline":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}}},"first_baseline":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":59}],"chrome_android":[{"added":59}],"edge":[{"added":79}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"last_baseline":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"partial_implementation":true,"added":59,"removed":86}],"chrome_android":[{"partial_implementation":true,"added":59,"removed":86}],"edge":[{"partial_implementation":true,"added":79,"removed":86}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"partial_implementation":true,"added":11}],"safari_ios":[{"partial_implementation":true,"added":11}]}}},"safe_unsafe":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":63}],"firefox_android":[{"added":63}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"space-evenly":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":60}],"chrome_android":[{"added":60}],"edge":[{"added":79}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"start_end":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":93}],"chrome_android":[{"added":93}],"edge":[{"added":93}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":15.6}],"safari_ios":[{"added":15.6}]}}},"stretch":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}}}},"grid_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-align/#align-justify-content","https://drafts.csswg.org/css-flexbox/#align-content-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":52}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"_aliasOf":"align-content"},"align-items":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/align-items","spec_url":["https://drafts.csswg.org/css-align/#align-items-property","https://drafts.csswg.org/css-flexbox/#align-items-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":21}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":20},{"prefix":"-webkit-","added":49}],"firefox_android":[{"added":20},{"prefix":"-webkit-","added":49}],"ie":[{"added":11}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"flex_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-align/#align-items-property","https://drafts.csswg.org/css-flexbox/#align-items-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":52},{"partial_implementation":true,"added":21,"removed":52}],"chrome_android":[{"added":52},{"partial_implementation":true,"added":25,"removed":52}],"edge":[{"added":12}],"firefox":[{"added":20}],"firefox_android":[{"added":20}],"ie":[{"added":11}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}},"baseline":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":21}],"chrome_android":[{"added":25}],"edge":[{"added":79}],"firefox":[{"added":28}],"firefox_android":[{"added":28}],"ie":[{"added":false}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"first_baseline":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":59}],"chrome_android":[{"added":59}],"edge":[{"added":79}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"last_baseline":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":108}],"chrome_android":[{"added":108}],"edge":[{"added":108}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":16.2}],"safari_ios":[{"added":16.2}]}}},"safe_unsafe":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":63}],"firefox_android":[{"added":63}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"start_end":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":93}],"chrome_android":[{"added":93}],"edge":[{"added":93}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}}},"grid_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-align/#align-items-property","https://drafts.csswg.org/css-flexbox/#align-items-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":52}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"start_end":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}}},"_aliasOf":"align-items"},"align-self":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/align-self","spec_url":["https://drafts.csswg.org/css-align/#align-self-property","https://drafts.csswg.org/css-flexbox/#align-items-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":21}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":20},{"prefix":"-webkit-","added":49}],"firefox_android":[{"added":20},{"prefix":"-webkit-","added":49}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"flex_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-align/#align-self-property","https://drafts.csswg.org/css-flexbox/#align-items-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":36},{"partial_implementation":true,"added":21,"removed":36}],"chrome_android":[{"added":36},{"partial_implementation":true,"added":25,"removed":36}],"edge":[{"added":12}],"firefox":[{"added":20}],"firefox_android":[{"added":20}],"ie":[{"added":11}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}},"baseline":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":21}],"chrome_android":[{"added":25}],"edge":[{"added":79}],"firefox":[{"added":28}],"firefox_android":[{"added":28}],"ie":[{"added":false}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"first_baseline":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":59}],"chrome_android":[{"added":59}],"edge":[{"added":79}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"last_baseline":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":108}],"chrome_android":[{"added":108}],"edge":[{"added":108}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":16.2}],"safari_ios":[{"added":16.2}]}}},"safe_unsafe":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":63}],"firefox_android":[{"added":63}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"start_end":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":93}],"chrome_android":[{"added":93}],"edge":[{"added":93}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"stretch":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}}}},"grid_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-align/#align-self-property","https://drafts.csswg.org/css-flexbox/#align-items-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":52}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"partial_implementation":true,"prefix":"-ms-","added":10}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"_aliasOf":"grid_context"},"_aliasOf":"align-self","-ms-grid_context":{"_aliasOf":"grid_context"}},"align-tracks":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/align-tracks","spec_url":"https://drafts.csswg.org/css-grid-3/#tracks-alignment","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.grid-template-masonry-value.enabled","type":"preference","value_to_set":"true"}],"added":77}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"all":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/all","spec_url":"https://drafts.csswg.org/css-cascade/#all-shorthand","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":37}],"chrome_android":[{"added":37}],"edge":[{"added":79}],"firefox":[{"added":27}],"firefox_android":[{"added":27}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"alt":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/alt","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":9},{"prefix":"-webkit-","added":8}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":8}]}},"_aliasOf":"alt"},"animation":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/animation","spec_url":"https://drafts.csswg.org/css-animations/#animation","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":43},{"prefix":"-webkit-","added":3}],"chrome_android":[{"added":43},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":3.2}]}},"_aliasOf":"animation"},"animation-composition":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/animation-composition","spec_url":"https://drafts.csswg.org/css-animations-2/#animation-composition","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":112}],"chrome_android":[{"added":112}],"edge":[{"added":112}],"firefox":[{"flags":[{"name":"layout.css.animation-composition.enabled","type":"preference","value_to_set":"true"}],"added":104}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"animation-delay":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/animation-delay","spec_url":"https://drafts.csswg.org/css-animations/#animation-delay","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":43},{"prefix":"-webkit-","added":3}],"chrome_android":[{"added":43},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":3.2}]}},"_aliasOf":"animation-delay"},"animation-direction":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/animation-direction","spec_url":"https://drafts.csswg.org/css-animations/#animation-direction","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":43},{"prefix":"-webkit-","added":3}],"chrome_android":[{"added":43},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":3.2}]}},"alternate-reverse":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":19}],"chrome_android":[{"added":25}],"edge":[{"added":12}],"firefox":[{"added":16}],"firefox_android":[{"added":16}],"ie":[{"added":10}],"safari":[{"added":6}],"safari_ios":[{"added":6}]}}},"reverse":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":19}],"chrome_android":[{"added":25}],"edge":[{"added":12}],"firefox":[{"added":16}],"firefox_android":[{"added":16}],"ie":[{"added":10}],"safari":[{"added":6}],"safari_ios":[{"added":6}]}}},"_aliasOf":"animation-direction"},"animation-duration":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/animation-duration","spec_url":"https://drafts.csswg.org/css-animations/#animation-duration","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":43},{"prefix":"-webkit-","added":3}],"chrome_android":[{"added":43},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"ie":[{"partial_implementation":true,"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":4.2}]}},"_aliasOf":"animation-duration"},"animation-fill-mode":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/animation-fill-mode","spec_url":"https://drafts.csswg.org/css-animations/#animation-fill-mode","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":43},{"prefix":"-webkit-","added":3}],"chrome_android":[{"added":43},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":5}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":4}]}},"_aliasOf":"animation-fill-mode"},"animation-iteration-count":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/animation-iteration-count","spec_url":"https://drafts.csswg.org/css-animations/#animation-iteration-count","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":43},{"prefix":"-webkit-","added":3}],"chrome_android":[{"added":43},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":3.2}]}},"_aliasOf":"animation-iteration-count"},"animation-name":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/animation-name","spec_url":"https://drafts.csswg.org/css-animations/#animation-name","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":43},{"prefix":"-webkit-","added":3}],"chrome_android":[{"added":43},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":3.2}]}},"_aliasOf":"animation-name"},"animation-play-state":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/animation-play-state","spec_url":"https://drafts.csswg.org/css-animations/#animation-play-state","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":43},{"prefix":"-webkit-","added":3}],"chrome_android":[{"added":43},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":2}]}},"_aliasOf":"animation-play-state"},"animation-timeline":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/animation-timeline","spec_url":"https://drafts.csswg.org/css-animations-2/#animation-timeline","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"flags":[{"name":"enable-experimental-web-platform-features","type":"preference","value_to_set":"enabled"}],"added":85}],"chrome_android":[{"flags":[{"name":"enable-experimental-web-platform-features","type":"preference","value_to_set":"enabled"}],"added":85}],"edge":[{"flags":[{"name":"enable-experimental-web-platform-features","type":"preference","value_to_set":"enabled"}],"added":85}],"firefox":[{"flags":[{"name":"layout.css.scroll-linked-animations.enabled","type":"preference","value_to_set":"true"}],"added":97}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"scroll":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/animation-timeline/scroll","spec_url":"https://drafts.csswg.org/scroll-animations/#funcdef-scroll","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.scroll-linked-animations.enabled","type":"preference","value_to_set":"true"}],"added":101}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"animation-timing-function":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/animation-timing-function","spec_url":"https://drafts.csswg.org/css-animations/#animation-timing-function","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":43},{"prefix":"-webkit-","added":3}],"chrome_android":[{"added":43},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":5}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":3.2}]}},"jump":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":77}],"chrome_android":[{"added":77}],"edge":[{"added":79}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":false}],"safari":[{"added":14}],"safari_ios":[{"added":14}]}}},"_aliasOf":"animation-timing-function"},"appearance":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/appearance","spec_url":"https://drafts.csswg.org/css-ui/#appearance-switching","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":84},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":84},{"prefix":"-webkit-","added":18}],"edge":[{"added":84},{"prefix":"-webkit-","added":12}],"firefox":[{"added":80},{"prefix":"-webkit-","added":64},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":80},{"prefix":"-webkit-","added":64},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":15.4},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":15.4},{"prefix":"-webkit-","added":1}]}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":83}],"chrome_android":[{"added":83}],"edge":[{"added":83}],"firefox":[{"added":80}],"firefox_android":[{"added":80}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"compat-auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"menulist-button":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":80},{"partial_implementation":true,"added":1}],"firefox_android":[{"added":80},{"partial_implementation":true,"added":4}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"none":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":54},{"partial_implementation":true,"added":1}],"firefox_android":[{"added":54},{"partial_implementation":true,"added":4}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":3}]}}},"textfield":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"_aliasOf":"appearance"},"aspect-ratio":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/aspect-ratio","spec_url":"https://drafts.csswg.org/css-sizing-4/#aspect-ratio","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":88}],"chrome_android":[{"added":88}],"edge":[{"added":88}],"firefox":[{"added":89}],"firefox_android":[{"added":89}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"backdrop-filter":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/backdrop-filter","spec_url":"https://drafts.fxtf.org/filter-effects-2/#BackdropFilterProperty","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":76}],"chrome_android":[{"added":76}],"edge":[{"added":17}],"firefox":[{"added":103}],"firefox_android":[{"added":103}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":9}],"safari_ios":[{"prefix":"-webkit-","added":9}]}},"_aliasOf":"backdrop-filter"},"backface-visibility":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/backface-visibility","spec_url":"https://drafts.csswg.org/css-transforms-2/#backface-visibility-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":36},{"prefix":"-webkit-","added":12}],"chrome_android":[{"added":36},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":10}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":10}],"ie":[{"added":10}],"safari":[{"added":15.4},{"prefix":"-webkit-","added":5.1}],"safari_ios":[{"added":15.4},{"prefix":"-webkit-","added":5}]}},"_aliasOf":"backface-visibility"},"background":{"SVG_image_as_background":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":4}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":3.1}],"safari_ios":[{"added":1}]}}},"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/background","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-background","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"background-clip":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":21}],"chrome_android":[{"added":25}],"edge":[{"added":12}],"firefox":[{"added":22}],"firefox_android":[{"added":22}],"ie":[{"added":9}],"safari":[{"added":5.1}],"safari_ios":[{"added":4}]}}},"background-origin":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":21}],"chrome_android":[{"added":25}],"edge":[{"added":12}],"firefox":[{"added":22}],"firefox_android":[{"added":22}],"ie":[{"added":9}],"safari":[{"added":5.1}],"safari_ios":[{"added":4}]}}},"background-size":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":21}],"chrome_android":[{"added":25}],"edge":[{"added":12}],"firefox":[{"added":9}],"firefox_android":[{"added":18}],"ie":[{"added":9}],"safari":[{"added":5.1}],"safari_ios":[{"added":4}]}}},"multiple_backgrounds":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":1.3}],"safari_ios":[{"added":1}]}}}},"background-attachment":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/background-attachment","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-background-attachment","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":3.2}]}},"fixed":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":2}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"partial_implementation":true,"added":14},{"added":3.1,"removed":14}],"safari_ios":[{"partial_implementation":true,"added":5}]}}},"local":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":25}],"firefox_android":[{"added":25}],"ie":[{"added":9}],"safari":[{"partial_implementation":true,"added":13},{"added":5,"removed":13}],"safari_ios":[{"partial_implementation":true,"added":13},{"added":4.2,"removed":13}]}}},"multiple_backgrounds":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":1.3}],"safari_ios":[{"added":3.2}]}}}},"background-blend-mode":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/background-blend-mode","spec_url":"https://drafts.fxtf.org/compositing/#background-blend-mode","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":35}],"chrome_android":[{"added":35}],"edge":[{"added":79}],"firefox":[{"added":30}],"firefox_android":[{"added":30}],"ie":[{"added":false}],"safari":[{"added":8}],"safari_ios":[{"added":8}]}}},"background-clip":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/background-clip","spec_url":"https://drafts.csswg.org/css-backgrounds/#background-clip","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":4},{"prefix":"-webkit-","added":49},{"partial_implementation":true,"prefix":"-moz-","added":1,"removed":4}],"firefox_android":[{"added":14},{"prefix":"-webkit-","added":49}],"ie":[{"added":9}],"safari":[{"added":14},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":14},{"prefix":"-webkit-","added":1}]}},"content-box":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":4}],"firefox_android":[{"added":14}],"ie":[{"added":9}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"text":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"partial_implementation":true,"added":3}],"chrome_android":[{"partial_implementation":true,"added":18}],"edge":[{"added":15},{"partial_implementation":true,"added":12}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":false}],"safari":[{"added":14},{"partial_implementation":true,"added":4}],"safari_ios":[{"added":14},{"partial_implementation":true,"added":3.2}]}}},"_aliasOf":"background-clip"},"background-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/background-color","spec_url":"https://drafts.csswg.org/css-backgrounds/#background-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"background-image":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/background-image","spec_url":"https://drafts.csswg.org/css-backgrounds/#background-image","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"element":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/element()","spec_url":"https://drafts.csswg.org/css-images-4/#element-notation","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"prefix":"-moz-","added":4}],"firefox_android":[{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"element"},"gradients":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/gradient","spec_url":"https://drafts.csswg.org/css-images-4/#gradients","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":10}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}}},"image-rect":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/-moz-image-rect","status":{"deprecated":false,"experimental":true,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"prefix":"-moz-","added":4}],"firefox_android":[{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"image-rect"},"image-set":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/image-set()","spec_url":"https://drafts.csswg.org/css-images-4/#image-set-notation","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"prefix":"-webkit-","added":21}],"chrome_android":[{"prefix":"-webkit-","added":25}],"edge":[{"prefix":"-webkit-","added":79}],"firefox":[{"added":88},{"prefix":"-webkit-","added":90}],"firefox_android":[{"added":88},{"prefix":"-webkit-","added":90}],"ie":[{"added":false}],"safari":[{"added":null},{"partial_implementation":true,"prefix":"-webkit-","added":6}],"safari_ios":[{"partial_implementation":true,"prefix":"-webkit-","added":6}]}},"_aliasOf":"image-set"},"multiple_backgrounds":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":1.3}],"safari_ios":[{"added":1}]}}},"svg_images":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":8}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":4}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":5}],"safari_ios":[{"added":5}]}}},"-moz-element":{"_aliasOf":"element"},"-moz-image-rect":{"_aliasOf":"image-rect"},"-webkit-image-set":{"_aliasOf":"image-set"}},"background-origin":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/background-origin","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-background-origin","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":4},{"prefix":"-webkit-","added":49},{"partial_implementation":true,"prefix":"-moz-","added":1,"removed":4}],"firefox_android":[{"added":14},{"prefix":"-webkit-","added":49}],"ie":[{"added":9}],"safari":[{"added":3},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":1},{"prefix":"-webkit-","added":1}]}},"content-box":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":4}],"firefox_android":[{"added":14}],"ie":[{"added":9}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"_aliasOf":"background-origin"},"background-position":{"4_value_syntax":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":25}],"chrome_android":[{"added":25}],"edge":[{"added":12}],"firefox":[{"added":13}],"firefox_android":[{"added":14}],"ie":[{"added":9}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/background-position","spec_url":"https://drafts.csswg.org/css-backgrounds/#background-position","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"multiple_backgrounds":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":1.3}],"safari_ios":[{"added":1}]}}}},"background-position-x":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/background-position-x","spec_url":"https://drafts.csswg.org/css-backgrounds-4/#background-position-longhands","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":6}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"two_value_syntax":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":9}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}}},"background-position-y":{"2_value_syntax":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":9}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/background-position-y","spec_url":"https://drafts.csswg.org/css-backgrounds-4/#background-position-longhands","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":6}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"background-repeat":{"2-value":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":3}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":13}],"firefox_android":[{"added":14}],"ie":[{"added":9}],"safari":[{"added":5}],"safari_ios":[{"added":4}]}}},"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/background-repeat","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-background-repeat","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"multiple_backgrounds":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":1.3}],"safari_ios":[{"added":1}]}}},"round_space":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":30}],"chrome_android":[{"added":30}],"edge":[{"added":12}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":9}],"safari":[{"added":8}],"safari_ios":[{"added":8}]}}}},"background-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/background-size","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-background-size","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":3},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":4},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":3.6,"removed":4}],"firefox_android":[{"added":4},{"prefix":"-webkit-","added":49}],"ie":[{"added":9}],"safari":[{"added":5},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":4.2},{"prefix":"-webkit-","added":1}]}},"contain_and_cover":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":3}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"_aliasOf":"background-size"},"baseline-source":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/baseline-source","spec_url":"https://drafts.csswg.org/css-inline/#baseline-source","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":111}],"chrome_android":[{"added":111}],"edge":[{"added":111}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"block-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/block-size","spec_url":"https://drafts.csswg.org/css-logical/#dimension-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"fit-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":94},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":94},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"fit-content"},"fit-content_function":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.fit-content-function.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"max-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"min-content"},"-moz-fit-content":{"_aliasOf":"fit-content"},"-moz-max-content":{"_aliasOf":"max-content"},"-moz-min-content":{"_aliasOf":"min-content"}},"border":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border","spec_url":"https://drafts.csswg.org/css-backgrounds/#propdef-border","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-block":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-block","spec_url":"https://drafts.csswg.org/css-logical/#propdef-border-block","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"border-block-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-block-color","spec_url":"https://drafts.csswg.org/css-logical/#propdef-border-block-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"border-block-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-block-end","spec_url":"https://drafts.csswg.org/css-logical/#border-shorthands","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"border-block-end-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-block-end-color","spec_url":"https://drafts.csswg.org/css-logical/#border-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"border-block-end-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-block-end-style","spec_url":"https://drafts.csswg.org/css-logical/#border-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"border-block-end-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-block-end-width","spec_url":"https://drafts.csswg.org/css-logical/#border-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"border-block-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-block-start","spec_url":"https://drafts.csswg.org/css-logical/#border-shorthands","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"border-block-start-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-block-start-color","spec_url":"https://drafts.csswg.org/css-logical/#border-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"border-block-start-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-block-start-style","spec_url":"https://drafts.csswg.org/css-logical/#border-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"border-block-start-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-block-start-width","spec_url":"https://drafts.csswg.org/css-logical/#border-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"border-block-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-block-style","spec_url":"https://drafts.csswg.org/css-logical/#propdef-border-block-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"border-block-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-block-width","spec_url":"https://drafts.csswg.org/css-logical/#propdef-border-block-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"border-bottom":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-bottom","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-shorthands","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-bottom-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-bottom-color","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-bottom-left-radius":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-bottom-left-radius","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-radius","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":4},{"prefix":"-webkit-","added":49},{"alternative_name":"-moz-border-radius-bottomleft","added":1,"removed":12}],"firefox_android":[{"added":4},{"prefix":"-webkit-","added":49},{"alternative_name":"-moz-border-radius-bottomleft","added":4,"removed":14}],"ie":[{"added":9}],"safari":[{"added":5},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":4.2},{"prefix":"-webkit-","added":1}]}},"elliptical_corners":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.5}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"percentages":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":4},{"partial_implementation":true,"added":1,"removed":4}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"_aliasOf":"border-bottom-left-radius"},"border-bottom-right-radius":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-bottom-right-radius","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-radius","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":4},{"prefix":"-webkit-","added":49},{"alternative_name":"-moz-border-radius-bottomright","added":1,"removed":12}],"firefox_android":[{"added":4},{"prefix":"-webkit-","added":49},{"alternative_name":"-moz-border-radius-bottomright","added":4,"removed":14}],"ie":[{"added":9}],"safari":[{"added":5},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":4.2},{"prefix":"-webkit-","added":1}]}},"elliptical_corners":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.5}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"percentages":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":4},{"partial_implementation":true,"added":1,"removed":4}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"_aliasOf":"border-bottom-right-radius"},"border-bottom-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-bottom-style","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-bottom-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-bottom-width","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-collapse":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-collapse","spec_url":"https://drafts.csswg.org/css2/#propdef-border-collapse","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5}],"safari":[{"added":1.2}],"safari_ios":[{"added":3}]}}},"border-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-color","spec_url":["https://drafts.csswg.org/css-logical/#logical-shorthand-keyword","https://drafts.csswg.org/css-backgrounds/#border-color"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-end-end-radius":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-end-end-radius","spec_url":"https://drafts.csswg.org/css-logical/#border-radius-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":89}],"chrome_android":[{"added":89}],"edge":[{"added":89}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"border-end-start-radius":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-end-start-radius","spec_url":"https://drafts.csswg.org/css-logical/#border-radius-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":89}],"chrome_android":[{"added":89}],"edge":[{"added":89}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"border-image":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-image","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-image","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":16},{"prefix":"-webkit-","added":7}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":15},{"prefix":"-moz-","added":3.5}],"firefox_android":[{"added":15},{"prefix":"-moz-","added":4}],"ie":[{"added":11}],"safari":[{"added":6},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":6},{"prefix":"-webkit-","added":3.2}]}},"fill":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":16}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":15}],"firefox_android":[{"added":15}],"ie":[{"added":11}],"safari":[{"added":6}],"safari_ios":[{"added":6}]}}},"gradient":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":7}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":29}],"firefox_android":[{"added":29}],"ie":[{"added":11}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}}},"optional_border_image_slice":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":16}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":15}],"firefox_android":[{"added":15}],"ie":[{"added":11}],"safari":[{"added":6}],"safari_ios":[{"added":6}]}}},"_aliasOf":"border-image"},"border-image-outset":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-image-outset","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-image-outset","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":15}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":15}],"firefox_android":[{"added":15}],"ie":[{"added":11}],"safari":[{"added":6}],"safari_ios":[{"added":6}]}}},"border-image-repeat":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-image-repeat","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-image-repeat","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":15}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":15}],"firefox_android":[{"added":15}],"ie":[{"added":11}],"safari":[{"added":6}],"safari_ios":[{"added":9.3}]}},"round":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":30}],"chrome_android":[{"added":30}],"edge":[{"added":12}],"firefox":[{"added":15}],"firefox_android":[{"added":15}],"ie":[{"added":11}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"space":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":56}],"chrome_android":[{"added":56}],"edge":[{"added":12}],"firefox":[{"added":50}],"firefox_android":[{"added":50}],"ie":[{"added":11}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}}},"border-image-slice":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-image-slice","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-image-slice","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":15}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":15}],"firefox_android":[{"added":15}],"ie":[{"added":11}],"safari":[{"added":6}],"safari_ios":[{"added":6}]}},"_aliasOf":"border-image-slice"},"border-image-source":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-image-source","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-image-source","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":15}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":15}],"firefox_android":[{"added":15}],"ie":[{"added":11}],"safari":[{"added":6}],"safari_ios":[{"added":6}]}}},"border-image-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-image-width","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-image-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":15}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":13}],"firefox_android":[{"added":14}],"ie":[{"added":11}],"safari":[{"added":6}],"safari_ios":[{"added":6}]}}},"border-inline":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-inline","spec_url":"https://drafts.csswg.org/css-logical/#propdef-border-inline","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"border-inline-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-inline-color","spec_url":"https://drafts.csswg.org/css-logical/#propdef-border-inline-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"border-inline-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-inline-end","spec_url":"https://drafts.csswg.org/css-logical/#border-shorthands","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"border-inline-end-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-inline-end-color","spec_url":"https://drafts.csswg.org/css-logical/#border-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41},{"alternative_name":"-moz-border-end-color","added":3}],"firefox_android":[{"added":41},{"alternative_name":"-moz-border-end-color","added":4}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"border-inline-end-color"},"border-inline-end-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-inline-end-style","spec_url":"https://drafts.csswg.org/css-logical/#border-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41},{"alternative_name":"-moz-border-end-style","added":3}],"firefox_android":[{"added":41},{"alternative_name":"-moz-border-end-style","added":4}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"border-inline-end-style"},"border-inline-end-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-inline-end-width","spec_url":"https://drafts.csswg.org/css-logical/#border-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41},{"alternative_name":"-moz-border-end-width","added":3}],"firefox_android":[{"added":41},{"alternative_name":"-moz-border-end-width","added":4}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"border-inline-end-width"},"border-inline-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-inline-start","spec_url":"https://drafts.csswg.org/css-logical/#border-shorthands","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"border-inline-start-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-inline-start-color","spec_url":"https://drafts.csswg.org/css-logical/#border-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41},{"alternative_name":"-moz-border-start-color","added":3}],"firefox_android":[{"added":41},{"alternative_name":"-moz-border-start-color","added":4}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"border-inline-start-color"},"border-inline-start-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-inline-start-style","spec_url":"https://drafts.csswg.org/css-logical/#border-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41},{"alternative_name":"-moz-border-start-style","added":3}],"firefox_android":[{"added":41},{"alternative_name":"-moz-border-start-style","added":4}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"border-inline-start-style"},"border-inline-start-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-inline-start-width","spec_url":"https://drafts.csswg.org/css-logical/#border-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"border-inline-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-inline-style","spec_url":"https://drafts.csswg.org/css-logical/#propdef-border-inline-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"border-inline-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-inline-width","spec_url":"https://drafts.csswg.org/css-logical/#propdef-border-inline-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"border-left":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-left","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-shorthands","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-left-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-left-color","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-left-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-left-style","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":14}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-left-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-left-width","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-radius":{"4_values_for_4_corners":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":4}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-radius","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-radius","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":4},{"prefix":"-moz-","added":1,"removed":12}],"firefox_android":[{"added":4},{"prefix":"-moz-","added":4,"removed":14}],"ie":[{"added":9}],"safari":[{"added":5},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":4.2},{"prefix":"-webkit-","added":1}]}},"elliptical_borders":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":4}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":3}],"safari_ios":[{"added":4.2}]}}},"percentages":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":8}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":4}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"_aliasOf":"border-radius"},"border-right":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-right","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-shorthands","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":14}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-right-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-right-color","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-right-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-right-style","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":14}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-right-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-right-width","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-spacing":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-spacing","spec_url":"https://drafts.csswg.org/css2/#separated-borders","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-start-end-radius":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-start-end-radius","spec_url":"https://drafts.csswg.org/css-logical/#border-radius-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":89}],"chrome_android":[{"added":89}],"edge":[{"added":89}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"border-start-start-radius":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-start-start-radius","spec_url":"https://drafts.csswg.org/css-logical/#border-radius-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":89}],"chrome_android":[{"added":89}],"edge":[{"added":89}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"border-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-style","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":3}]}}},"border-top":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-top","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-shorthands","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-top-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-top-color","spec_url":"https://drafts.csswg.org/css-backgrounds/#border-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-top-left-radius":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-top-left-radius","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-radius","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":4},{"prefix":"-webkit-","added":49},{"alternative_name":"-moz-border-radius-topleft","added":1,"removed":12}],"firefox_android":[{"added":4},{"prefix":"-webkit-","added":49},{"alternative_name":"-moz-border-radius-topleft","added":4,"removed":14}],"ie":[{"added":9}],"safari":[{"added":5},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":4.2},{"prefix":"-webkit-","added":1}]}},"elliptical_corners":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.5}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"percentages":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":4},{"partial_implementation":true,"added":1,"removed":4}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"_aliasOf":"border-top-left-radius"},"border-top-right-radius":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-top-right-radius","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-radius","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":4},{"prefix":"-webkit-","added":49},{"alternative_name":"-moz-border-radius-topright","added":1,"removed":12}],"firefox_android":[{"added":4},{"prefix":"-webkit-","added":49},{"alternative_name":"-moz-border-radius-topright","added":4,"removed":14}],"ie":[{"added":9}],"safari":[{"added":5},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":4.2},{"prefix":"-webkit-","added":1}]}},"elliptical_corners":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.5}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"percentages":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":4},{"partial_implementation":true,"added":1,"removed":4}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"_aliasOf":"border-top-right-radius"},"border-top-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-top-style","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-top-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-top-width","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"border-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/border-width","spec_url":"https://drafts.csswg.org/css-backgrounds/#the-border-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":3}]}}},"bottom":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/bottom","spec_url":"https://drafts.csswg.org/css-position/#insets","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"box-align":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/box-align","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":12}],"firefox":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":3},{"prefix":"-khtml-","added":1.1,"removed":3}],"safari_ios":[{"prefix":"-webkit-","added":1}]}},"_aliasOf":"box-align"},"box-decoration-break":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/box-decoration-break","spec_url":"https://drafts.csswg.org/css-break/#break-decoration","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"prefix":"-webkit-","added":22}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":79}],"firefox":[{"added":32},{"alternative_name":"-moz-background-inline-policy","added":1,"removed":32}],"firefox_android":[{"added":32},{"alternative_name":"-moz-background-inline-policy","added":4,"removed":32}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":7}],"safari_ios":[{"prefix":"-webkit-","added":7}]}},"_aliasOf":"box-decoration-break"},"box-direction":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/box-direction","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":12}],"firefox":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":3},{"prefix":"-khtml-","added":1.1,"removed":3}],"safari_ios":[{"prefix":"-webkit-","added":1}]}},"_aliasOf":"box-direction"},"box-flex":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/box-flex","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":12}],"firefox":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":3},{"prefix":"-khtml-","added":1.1,"removed":3}],"safari_ios":[{"prefix":"-webkit-","added":1}]}},"_aliasOf":"box-flex"},"box-flex-group":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/box-flex-group","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"prefix":"-webkit-","added":1,"removed":67}],"chrome_android":[{"prefix":"-webkit-","added":18,"removed":67}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":3},{"prefix":"-khtml-","added":1.1,"removed":3}],"safari_ios":[{"prefix":"-webkit-","added":1}]}},"_aliasOf":"box-flex-group"},"box-lines":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/box-lines","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"prefix":"-webkit-","added":1,"removed":67}],"chrome_android":[{"prefix":"-webkit-","added":18,"removed":67}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":3},{"prefix":"-khtml-","added":1.1,"removed":3}],"safari_ios":[{"prefix":"-webkit-","added":1}]}},"_aliasOf":"box-lines"},"box-ordinal-group":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/box-ordinal-group","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":12}],"firefox":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":3},{"prefix":"-khtml-","added":1.1,"removed":3}],"safari_ios":[{"prefix":"-webkit-","added":1}]}},"_aliasOf":"box-ordinal-group"},"box-orient":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/box-orient","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":12}],"firefox":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":3},{"prefix":"-khtml-","added":1.1,"removed":3}],"safari_ios":[{"prefix":"-webkit-","added":1}]}},"_aliasOf":"box-orient"},"box-pack":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/box-pack","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":12}],"firefox":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":3},{"prefix":"-khtml-","added":1.1,"removed":3}],"safari_ios":[{"prefix":"-webkit-","added":1}]}},"_aliasOf":"box-pack"},"box-shadow":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/box-shadow","spec_url":"https://drafts.csswg.org/css-backgrounds/#box-shadow","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":10},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12}],"firefox":[{"added":4},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":3.5,"removed":13}],"firefox_android":[{"added":4},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4,"removed":14}],"ie":[{"added":9}],"safari":[{"added":5.1},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":5},{"prefix":"-webkit-","added":1}]}},"inset":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":10},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12}],"firefox":[{"added":4},{"prefix":"-moz-","added":3.5,"removed":13}],"firefox_android":[{"added":4},{"prefix":"-moz-","added":4,"removed":14}],"ie":[{"partial_implementation":true,"added":9}],"safari":[{"added":5.1},{"prefix":"-webkit-","added":5}],"safari_ios":[{"added":5},{"prefix":"-webkit-","added":4.2}]}},"_aliasOf":"inset"},"multiple_shadows":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":10},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12}],"firefox":[{"added":4},{"prefix":"-moz-","added":3.5,"removed":13}],"firefox_android":[{"added":4},{"prefix":"-moz-","added":4,"removed":14}],"ie":[{"added":9}],"safari":[{"added":5.1},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":5},{"prefix":"-webkit-","added":1}]}},"_aliasOf":"multiple_shadows"},"spread_radius":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":10},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12}],"firefox":[{"added":4},{"prefix":"-moz-","added":3.5,"removed":13}],"firefox_android":[{"added":4},{"prefix":"-moz-","added":4,"removed":14}],"ie":[{"added":9}],"safari":[{"added":5.1},{"prefix":"-webkit-","added":5}],"safari_ios":[{"added":5},{"prefix":"-webkit-","added":4.2}]}},"_aliasOf":"spread_radius"},"_aliasOf":"box-shadow","-webkit-inset":{"_aliasOf":"inset"},"-moz-inset":{"_aliasOf":"inset"},"-webkit-multiple_shadows":{"_aliasOf":"multiple_shadows"},"-moz-multiple_shadows":{"_aliasOf":"multiple_shadows"},"-webkit-spread_radius":{"_aliasOf":"spread_radius"},"-moz-spread_radius":{"_aliasOf":"spread_radius"}},"box-sizing":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/box-sizing","spec_url":"https://drafts.csswg.org/css-sizing/#box-sizing","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":10},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":29},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":29},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":8}],"safari":[{"added":5.1},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":6},{"prefix":"-webkit-","added":1}]}},"padding-box":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":1,"removed":50}],"firefox_android":[{"added":4,"removed":50}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"box-sizing"},"break-after":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/break-after","spec_url":["https://drafts.csswg.org/css-break/#break-between","https://drafts.csswg.org/css-regions/#region-flow-break","https://drafts.csswg.org/css-multicol/#break-before-break-after-break-inside"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":10}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}},"multicol_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-break/#break-between","https://drafts.csswg.org/css-regions/#region-flow-break","https://drafts.csswg.org/css-multicol/#break-before-break-after-break-inside"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"partial_implementation":true,"added":65}],"firefox_android":[{"partial_implementation":true,"added":65}],"ie":[{"added":10}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"always":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"partial_implementation":true,"added":65}],"firefox_android":[{"partial_implementation":true,"added":65}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"avoid":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":102}],"chrome_android":[{"added":102}],"edge":[{"added":102}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"avoid-column":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":102}],"chrome_android":[{"added":102}],"edge":[{"added":102},{"added":12,"removed":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":10}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"column":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":10}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"paged_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-break/#break-between","https://drafts.csswg.org/css-regions/#region-flow-break","https://drafts.csswg.org/css-multicol/#break-before-break-after-break-inside"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":10}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}},"always":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":10}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"avoid-page":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":10}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}}},"page":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":10}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}}},"recto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}}},"break-before":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/break-before","spec_url":["https://drafts.csswg.org/css-break/#break-between","https://drafts.csswg.org/css-regions/#region-flow-break","https://drafts.csswg.org/css-multicol/#break-before-break-after-break-inside"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":10}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}},"multicol_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-break/#break-between","https://drafts.csswg.org/css-regions/#region-flow-break","https://drafts.csswg.org/css-multicol/#break-before-break-after-break-inside"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"partial_implementation":true,"added":65}],"firefox_android":[{"partial_implementation":true,"added":65}],"ie":[{"added":10}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"always":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"partial_implementation":true,"added":65}],"firefox_android":[{"partial_implementation":true,"added":65}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"avoid":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":102}],"chrome_android":[{"added":102}],"edge":[{"added":102}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"avoid-column":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":102}],"chrome_android":[{"added":102}],"edge":[{"added":102},{"added":12,"removed":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":10}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"column":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":51}],"chrome_android":[{"added":51}],"edge":[{"added":12}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":10}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"paged_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-break/#break-between","https://drafts.csswg.org/css-regions/#region-flow-break","https://drafts.csswg.org/css-multicol/#break-before-break-after-break-inside"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":10}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}},"always":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":10}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"page":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":10}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}}},"recto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}}},"break-inside":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/break-inside","spec_url":["https://drafts.csswg.org/css-break/#break-within","https://drafts.csswg.org/css-regions/#region-flow-break","https://drafts.csswg.org/css-multicol/#break-before-break-after-break-inside"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":10}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}},"multicol_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-break/#break-within","https://drafts.csswg.org/css-regions/#region-flow-break","https://drafts.csswg.org/css-multicol/#break-before-break-after-break-inside"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":10}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}},"avoid-column":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":92}],"firefox_android":[{"added":92}],"ie":[{"added":10}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}}}},"paged_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-break/#break-within","https://drafts.csswg.org/css-regions/#region-flow-break","https://drafts.csswg.org/css-multicol/#break-before-break-after-break-inside"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":10}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}},"avoid-page":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":51}],"chrome_android":[{"added":51}],"edge":[{"added":12}],"firefox":[{"added":92}],"firefox_android":[{"added":92}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}}},"caption-side":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/caption-side","spec_url":"https://drafts.csswg.org/css-logical/#caption-side","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"non_standard_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":1,"removed":87}],"firefox_android":[{"added":4,"removed":87}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}},"writing-mode_relative_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":42}],"firefox_android":[{"added":42}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"caret-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/caret-color","spec_url":"https://drafts.csswg.org/css-ui/#caret-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":11.1}],"safari_ios":[{"partial_implementation":true,"added":11.3}]}}},"clear":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/clear","spec_url":["https://drafts.csswg.org/css2/#propdef-clear","https://drafts.csswg.org/css-logical/#float-clear"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"flow_relative_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"flags":[{"name":"enable-experimental-web-platform-features","type":"preference","value_to_set":"enabled"}],"added":70}],"chrome_android":[{"flags":[{"name":"enable-experimental-web-platform-features","type":"preference","value_to_set":"enabled"}],"added":70}],"edge":[{"flags":[{"name":"enable-experimental-web-platform-features","type":"preference","value_to_set":"enabled"}],"added":79}],"firefox":[{"added":55}],"firefox_android":[{"added":55}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}}},"clip":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/clip","spec_url":"https://drafts.fxtf.org/css-masking/#clip-property","status":{"deprecated":true,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"clip-path":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/clip-path","spec_url":["https://drafts.fxtf.org/css-masking/#the-clip-path","https://drafts.csswg.org/css-shapes/#supported-basic-shapes"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":55},{"prefix":"-webkit-","added":23}],"chrome_android":[{"added":55},{"prefix":"-webkit-","added":25}],"edge":[{"added":79},{"partial_implementation":true,"added":12}],"firefox":[{"added":3.5}],"firefox_android":[{"added":4}],"ie":[{"partial_implementation":true,"added":10}],"safari":[{"added":9.1},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9.3},{"prefix":"-webkit-","added":7}]}},"animations":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":55}],"chrome_android":[{"added":55}],"edge":[{"added":79}],"firefox":[{"added":49}],"firefox_android":[{"added":49}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"basic_shape":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":23}],"chrome_android":[{"added":25}],"edge":[{"added":79}],"firefox":[{"added":54}],"firefox_android":[{"added":54}],"ie":[{"added":false}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"fill_and_stroke_box":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":51}],"firefox_android":[{"added":51}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}},"html":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":23}],"chrome_android":[{"added":25}],"edge":[{"added":79}],"firefox":[{"added":3.5}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"path":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":88}],"chrome_android":[{"added":88}],"edge":[{"added":88}],"firefox":[{"added":71}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":13.1},{"prefix":"-webkit-","added":10}],"safari_ios":[{"added":13},{"prefix":"-webkit-","added":10}]}},"_aliasOf":"path"},"svg":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":23}],"chrome_android":[{"added":25}],"edge":[{"added":12}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":10}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"_aliasOf":"clip-path","-webkit-path":{"_aliasOf":"path"}},"color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/color","spec_url":"https://drafts.csswg.org/css-color/#the-color-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":3}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"color-scheme":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/color-scheme","spec_url":"https://drafts.csswg.org/css-color-adjust/#color-scheme-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":81}],"chrome_android":[{"added":81}],"edge":[{"added":81}],"firefox":[{"added":96}],"firefox_android":[{"added":96}],"ie":[{"added":false}],"safari":[{"added":13}],"safari_ios":[{"added":13}]}},"only_dark":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":81}],"chrome_android":[{"added":81}],"edge":[{"added":81}],"firefox":[{"added":96}],"firefox_android":[{"added":96}],"ie":[{"added":false}],"safari":[{"added":13}],"safari_ios":[{"added":13}]}}},"only_light":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":81,"removed":85}],"chrome_android":[{"added":81,"removed":85}],"edge":[{"added":81,"removed":85}],"firefox":[{"added":96}],"firefox_android":[{"added":96}],"ie":[{"added":false}],"safari":[{"added":13}],"safari_ios":[{"added":13}]}}}},"column-count":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/column-count","spec_url":"https://drafts.csswg.org/css-multicol/#cc","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":50},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":52},{"prefix":"-moz-","added":1.5,"removed":74}],"firefox_android":[{"added":52},{"prefix":"-moz-","added":4}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":1}]}},"_aliasOf":"column-count"},"column-fill":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/column-fill","spec_url":"https://drafts.csswg.org/css-multicol/#cf","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12}],"firefox":[{"added":52},{"prefix":"-moz-","added":13,"removed":74}],"firefox_android":[{"added":52},{"prefix":"-moz-","added":14}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":8}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":8}]}},"balance-all":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"column-fill"},"column-gap":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/column-gap","spec_url":["https://drafts.csswg.org/css-align/#column-row-gap","https://drafts.csswg.org/css-grid/#gutters","https://drafts.csswg.org/css-multicol/#column-gap"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":4}],"ie":[{"added":10}],"safari":[{"added":3}],"safari_ios":[{"added":2}]}},"flex_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-align/#column-row-gap","https://drafts.csswg.org/css-grid/#gutters","https://drafts.csswg.org/css-multicol/#column-gap"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":84}],"chrome_android":[{"added":84}],"edge":[{"added":84}],"firefox":[{"added":63}],"firefox_android":[{"added":63}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"grid_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-align/#column-row-gap","https://drafts.csswg.org/css-grid/#gutters","https://drafts.csswg.org/css-multicol/#column-gap"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":66},{"alternative_name":"grid-column-gap","added":57}],"chrome_android":[{"added":66},{"alternative_name":"grid-column-gap","added":57}],"edge":[{"added":16},{"alternative_name":"grid-column-gap","added":16}],"firefox":[{"added":61},{"alternative_name":"grid-column-gap","added":52}],"firefox_android":[{"added":61},{"alternative_name":"grid-column-gap","added":52}],"ie":[{"added":false}],"safari":[{"added":12},{"alternative_name":"grid-column-gap","added":10.1}],"safari_ios":[{"added":12},{"alternative_name":"grid-column-gap","added":10.3}]}},"_aliasOf":"grid_context"},"multicol_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-align/#column-row-gap","https://drafts.csswg.org/css-grid/#gutters","https://drafts.csswg.org/css-multicol/#column-gap"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":50},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":52},{"prefix":"-moz-","added":1.5,"removed":74}],"firefox_android":[{"added":52},{"prefix":"-moz-","added":4}],"ie":[{"added":10}],"safari":[{"added":10},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":10},{"prefix":"-webkit-","added":3}]}},"calc_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":66}],"chrome_android":[{"added":66}],"edge":[{"added":16}],"firefox":[{"added":61}],"firefox_android":[{"added":61}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"percentage_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":66}],"chrome_android":[{"added":66}],"edge":[{"added":16}],"firefox":[{"added":61}],"firefox_android":[{"added":61}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"multicol_context"},"grid-column-gap":{"_aliasOf":"grid_context"},"-webkit-multicol_context":{"_aliasOf":"multicol_context"},"-moz-multicol_context":{"_aliasOf":"multicol_context"}},"column-rule":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/column-rule","spec_url":"https://drafts.csswg.org/css-multicol/#column-rule","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":50},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":52},{"prefix":"-moz-","added":3.5,"removed":74}],"firefox_android":[{"added":52},{"prefix":"-moz-","added":4}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":1}]}},"_aliasOf":"column-rule"},"column-rule-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/column-rule-color","spec_url":"https://drafts.csswg.org/css-multicol/#crc","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":50},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":52},{"prefix":"-moz-","added":3.5,"removed":74}],"firefox_android":[{"added":52},{"prefix":"-moz-","added":4}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":1}]}},"_aliasOf":"column-rule-color"},"column-rule-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/column-rule-style","spec_url":"https://drafts.csswg.org/css-multicol/#crs","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":50},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":52},{"prefix":"-moz-","added":3.5,"removed":74}],"firefox_android":[{"added":52},{"prefix":"-moz-","added":4}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":1}]}},"_aliasOf":"column-rule-style"},"column-rule-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/column-rule-width","spec_url":"https://drafts.csswg.org/css-multicol/#crw","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":50},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":52},{"prefix":"-moz-","added":3.5,"removed":74}],"firefox_android":[{"added":52},{"prefix":"-moz-","added":4}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":1}]}},"_aliasOf":"column-rule-width"},"column-span":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/column-span","spec_url":"https://drafts.csswg.org/css-multicol/#column-span","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50},{"prefix":"-webkit-","added":6}],"chrome_android":[{"added":50},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":71}],"firefox_android":[{"added":79}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":5.1}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":5}]}},"_aliasOf":"column-span"},"column-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/column-width","spec_url":["https://drafts.csswg.org/css-sizing/#column-sizing","https://drafts.csswg.org/css-multicol/#cw"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":50},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":50},{"prefix":"-moz-","added":1.5,"removed":74}],"firefox_android":[{"added":50},{"prefix":"-moz-","added":4}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":1}]}},"intrinsic_sizes":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"column-width"},"columns":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/columns","spec_url":"https://drafts.csswg.org/css-multicol/#columns","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":50},{"prefix":"-webkit-","added":50}],"chrome_android":[{"added":50}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":52},{"prefix":"-moz-","added":9,"removed":74}],"firefox_android":[{"added":52},{"prefix":"-moz-","added":22}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":3.2}]}},"_aliasOf":"columns"},"contain":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/contain","spec_url":"https://drafts.csswg.org/css-contain/#contain-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":52}],"chrome_android":[{"added":52}],"edge":[{"added":79}],"firefox":[{"added":69}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}},"inline-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/contain#inline-size","spec_url":"https://drafts.csswg.org/css-contain-3/#valdef-contain-inline-size","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":105}],"chrome_android":[{"added":105}],"edge":[{"added":105}],"firefox":[{"added":101}],"firefox_android":[{"added":101}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/contain#style","spec_url":"https://drafts.csswg.org/css-contain/#valdef-contain-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":52}],"chrome_android":[{"added":52}],"edge":[{"added":79}],"firefox":[{"added":103}],"firefox_android":[{"added":103}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}}},"contain-intrinsic-block-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/contain-intrinsic-contain-intrinsic-block-size","spec_url":"https://drafts.csswg.org/css-sizing-4/#propdef-contain-intrinsic-block-size","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":95}],"chrome_android":[{"added":95}],"edge":[{"added":95}],"firefox":[{"added":107}],"firefox_android":[{"added":107}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"contain-intrinsic-height":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/contain-intrinsic-height","spec_url":"https://drafts.csswg.org/css-sizing-4/#propdef-contain-intrinsic-height","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":83}],"chrome_android":[{"added":83}],"edge":[{"added":83}],"firefox":[{"added":107}],"firefox_android":[{"added":107}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"contain-intrinsic-inline-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/contain-intrinsic-contain-intrinsic-inline-size","spec_url":"https://drafts.csswg.org/css-sizing-4/#propdef-contain-intrinsic-inline-size","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":95}],"chrome_android":[{"added":95}],"edge":[{"added":95}],"firefox":[{"added":107}],"firefox_android":[{"added":107}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"contain-intrinsic-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/contain-intrinsic-size","spec_url":"https://drafts.csswg.org/css-sizing-4/#propdef-contain-intrinsic-size","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":83}],"chrome_android":[{"added":83}],"edge":[{"added":83}],"firefox":[{"added":107}],"firefox_android":[{"added":107}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"contain-intrinsic-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/contain-intrinsic-width","spec_url":"https://drafts.csswg.org/css-sizing-4/#propdef-contain-intrinsic-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":83}],"chrome_android":[{"added":83}],"edge":[{"added":83}],"firefox":[{"added":107}],"firefox_android":[{"added":107}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"container":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/container","spec_url":"https://drafts.csswg.org/css-contain-3/#container-shorthand","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":105}],"chrome_android":[{"added":105}],"edge":[{"added":105}],"firefox":[{"added":110}],"firefox_android":[{"added":110}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"container-name":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/container-name","spec_url":"https://drafts.csswg.org/css-contain-3/#container-name","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":105}],"chrome_android":[{"added":105}],"edge":[{"added":105}],"firefox":[{"added":110}],"firefox_android":[{"added":110}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"container-type":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/container-type","spec_url":"https://drafts.csswg.org/css-contain-3/#container-type","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":105}],"chrome_android":[{"added":105}],"edge":[{"added":105}],"firefox":[{"added":110}],"firefox_android":[{"added":110}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"content":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/content","spec_url":"https://drafts.csswg.org/css-content/#content-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"alt_text":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":77}],"chrome_android":[{"added":77}],"edge":[{"added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"element_replacement":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":28}],"chrome_android":[{"added":28}],"edge":[{"added":79}],"firefox":[{"added":63}],"firefox_android":[{"added":63}],"ie":[{"added":false}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}}},"none_applies_to_elements":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.element-content-none.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"url":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/url()","spec_url":"https://drafts.csswg.org/css-values/#urls","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}}},"content-visibility":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/content-visibility","spec_url":"https://drafts.csswg.org/css-contain/#content-visibility","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":85}],"chrome_android":[{"added":85}],"edge":[{"added":85}],"firefox":[{"added":null},{"flags":[{"name":"layout.css.content-visibility.enabled","type":"preference","value_to_set":"true"}],"added":109}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"counter-increment":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/counter-increment","spec_url":"https://drafts.csswg.org/css-lists/#increment-set","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":2}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":25}],"ie":[{"added":8}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"counter-reset":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/counter-reset","spec_url":"https://drafts.csswg.org/css-lists/#counter-reset","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":2}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":25}],"ie":[{"added":8}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}},"reset_does_not_affect_siblings":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":82}],"firefox_android":[{"added":82}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"reversed":{"__compat":{"spec_url":"https://drafts.csswg.org/css-lists/#css-counter-reversed","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":96}],"firefox_android":[{"added":96}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"counter-set":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/counter-set","spec_url":"https://drafts.csswg.org/css-lists/#propdef-counter-set","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":85}],"chrome_android":[{"added":85}],"edge":[{"added":85}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"cursor":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/cursor","spec_url":"https://drafts.csswg.org/css-ui/#cursor","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":95}],"ie":[{"added":4}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}},"alias":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":10}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"all-scroll":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":6}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":95}],"ie":[{"added":4}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"bidirectional_resize":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":10}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"cell":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":10}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"col-resize":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":6}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"context-menu":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":10}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"copy":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":10}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"crosshair":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":95}],"ie":[{"added":4}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"default":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":95}],"ie":[{"added":4}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"grab":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":68},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":68},{"prefix":"-webkit-","added":18}],"edge":[{"added":14}],"firefox":[{"added":27},{"prefix":"-moz-","added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":1}]}},"_aliasOf":"grab"},"help":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":95}],"ie":[{"added":4}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"inherit":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":95}],"ie":[{"added":8}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"move":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":95}],"ie":[{"added":4}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"no-drop":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":6}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"none":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":5}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3}],"firefox_android":[{"added":95}],"ie":[{"added":9}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"not-allowed":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":6}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"pointer":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":95}],"ie":[{"added":6}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"progress":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":95}],"ie":[{"added":6}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"row-resize":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":6}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"text":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":95}],"ie":[{"added":4}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"unidirectional_resize":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":95}],"ie":[{"added":4}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"url":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":6}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"url_positioning_syntax":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"vertical-text":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":95}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"wait":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":95}],"ie":[{"added":4}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"zoom":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":37},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":37},{"prefix":"-webkit-","added":18}],"edge":[{"added":12}],"firefox":[{"added":24},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":95}],"ie":[{"added":false}],"safari":[{"added":9},{"prefix":"-webkit-","added":3}],"safari_ios":[{"added":1}]}},"_aliasOf":"zoom"},"-webkit-grab":{"_aliasOf":"grab"},"-moz-grab":{"_aliasOf":"grab"},"-webkit-zoom":{"_aliasOf":"zoom"},"-moz-zoom":{"_aliasOf":"zoom"}},"custom-property":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/--*","spec_url":"https://drafts.csswg.org/css-variables/#defining-variables","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":49}],"chrome_android":[{"added":49}],"edge":[{"added":15}],"firefox":[{"added":31}],"firefox_android":[{"added":31}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}},"env":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/env()","spec_url":"https://drafts.csswg.org/css-env/#env-function","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":false}],"safari":[{"added":11.1},{"alternative_name":"constant","added":11,"removed":11.1}],"safari_ios":[{"added":11.3},{"alternative_name":"constant","added":11,"removed":11.3}]}},"safe-area-inset-bottom":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/env()","spec_url":"https://drafts.csswg.org/css-env/#safe-area-insets","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"safe-area-inset-left":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/env()","spec_url":"https://drafts.csswg.org/css-env/#safe-area-insets","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"safe-area-inset-right":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/env()","spec_url":"https://drafts.csswg.org/css-env/#safe-area-insets","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"safe-area-inset-top":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/env()","spec_url":"https://drafts.csswg.org/css-env/#safe-area-insets","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"titlebar-area-height":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/env()","spec_url":"https://wicg.github.io/window-controls-overlay/#title-bar-area-env-variables","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":93},{"partial_implementation":true,"added":92,"removed":93}],"chrome_android":[{"added":93},{"partial_implementation":true,"added":92,"removed":93}],"edge":[{"added":93},{"partial_implementation":true,"added":92,"removed":93}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"titlebar-area-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/env()","spec_url":"https://wicg.github.io/window-controls-overlay/#title-bar-area-env-variables","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":93},{"partial_implementation":true,"added":92,"removed":93}],"chrome_android":[{"added":93},{"partial_implementation":true,"added":92,"removed":93}],"edge":[{"added":93},{"partial_implementation":true,"added":92,"removed":93}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"titlebar-area-x":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/env()","spec_url":"https://wicg.github.io/window-controls-overlay/#title-bar-area-env-variables","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":93},{"partial_implementation":true,"added":92,"removed":93}],"chrome_android":[{"added":93},{"partial_implementation":true,"added":92,"removed":93}],"edge":[{"added":93},{"partial_implementation":true,"added":92,"removed":93}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"titlebar-area-y":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/env()","spec_url":"https://wicg.github.io/window-controls-overlay/#title-bar-area-env-variables","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":93},{"partial_implementation":true,"added":92,"removed":93}],"chrome_android":[{"added":93},{"partial_implementation":true,"added":92,"removed":93}],"edge":[{"added":93},{"partial_implementation":true,"added":92,"removed":93}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"env"},"var":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/var()","spec_url":"https://drafts.csswg.org/css-variables/#using-variables","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":49}],"chrome_android":[{"added":49}],"edge":[{"added":15}],"firefox":[{"added":31}],"firefox_android":[{"added":31}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"constant":{"_aliasOf":"env"}},"direction":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/direction","spec_url":"https://drafts.csswg.org/css-writing-modes/#direction","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":2}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"display":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/display","spec_url":"https://drafts.csswg.org/css-display/#the-display-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"contents":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":65}],"chrome_android":[{"added":65}],"edge":[{"added":79}],"firefox":[{"added":37}],"firefox_android":[{"added":37}],"ie":[{"added":false}],"safari":[{"added":11.1}],"safari_ios":[{"added":11.3}]}},"contents_unusual":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":65}],"chrome_android":[{"added":65}],"edge":[{"added":79}],"firefox":[{"added":59}],"firefox_android":[{"added":59}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"display-outside":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/display-outside","spec_url":"https://drafts.csswg.org/css-display/#typedef-display-outside","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"flex":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":21}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12}],"firefox":[{"added":20}],"firefox_android":[{"added":20}],"ie":[{"partial_implementation":true,"added":11},{"alternative_name":"-ms-flexbox","partial_implementation":true,"added":8}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"flex"},"flow-root":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":58}],"chrome_android":[{"added":58}],"edge":[{"added":79}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":13}],"safari_ios":[{"added":13}]}}},"grid":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16},{"prefix":"-ms-","added":12}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"partial_implementation":true,"prefix":"-ms-","added":10}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"_aliasOf":"grid"},"inline-block":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8},{"partial_implementation":true,"added":6}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"inline-flex":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":21}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12}],"firefox":[{"added":20}],"firefox_android":[{"added":20}],"ie":[{"added":11},{"alternative_name":"-ms-inline-flexbox","added":8}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"inline-flex"},"inline-grid":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16},{"prefix":"-ms-","added":12}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"partial_implementation":true,"prefix":"-ms-","added":10}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"_aliasOf":"inline-grid"},"inline-table":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"list-item":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/display-listitem","spec_url":"https://drafts.csswg.org/css-display/#typedef-display-listitem","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":6}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"legend-support":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":71}],"chrome_android":[{"added":71}],"edge":[{"added":79}],"firefox":[{"added":64}],"firefox_android":[{"added":64}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"math":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":87}],"chrome_android":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":87}],"edge":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":87}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"multi-keyword_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":70}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"none":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"ruby_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79}],"firefox":[{"added":38}],"firefox_android":[{"added":38}],"ie":[{"added":7}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"table_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"-webkit-flex":{"_aliasOf":"flex"},"-ms-flexbox":{"_aliasOf":"flex"},"-ms-grid":{"_aliasOf":"grid"},"-webkit-inline-flex":{"_aliasOf":"inline-flex"},"-ms-inline-flexbox":{"_aliasOf":"inline-flex"},"-ms-inline-grid":{"_aliasOf":"inline-grid"}},"empty-cells":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/empty-cells","spec_url":"https://drafts.csswg.org/css2/#empty-cells","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"filter":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/filter","spec_url":"https://drafts.fxtf.org/filter-effects/#FilterProperty","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":53},{"prefix":"-webkit-","added":18}],"chrome_android":[{"added":53}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":35},{"prefix":"-webkit-","added":49}],"firefox_android":[{"added":35},{"prefix":"-webkit-","added":49}],"ie":[{"added":false}],"safari":[{"added":9.1},{"prefix":"-webkit-","added":6}],"safari_ios":[{"added":9.3},{"prefix":"-webkit-","added":6}]}},"svg":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":35}],"firefox_android":[{"added":35}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"filter"},"flex":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/flex","spec_url":"https://drafts.csswg.org/css-flexbox/#flex-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":21}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":20},{"prefix":"-webkit-","added":49}],"firefox_android":[{"added":20},{"prefix":"-webkit-","added":49}],"ie":[{"added":11},{"prefix":"-ms-","added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"flex"},"flex-basis":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/flex-basis","spec_url":"https://drafts.csswg.org/css-flexbox/#flex-basis-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":22}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":22},{"prefix":"-webkit-","added":49}],"firefox_android":[{"added":22},{"prefix":"-webkit-","added":49}],"ie":[{"added":11}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":22}],"chrome_android":[{"added":25}],"edge":[{"added":12}],"firefox":[{"added":22}],"firefox_android":[{"added":22}],"ie":[{"added":11}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"auto"},"content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":94}],"chrome_android":[{"added":94}],"edge":[{"added":94},{"added":12,"removed":79}],"firefox":[{"added":61}],"firefox_android":[{"added":61}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"fit-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":94}],"chrome_android":[{"added":94}],"edge":[{"added":94}],"firefox":[{"added":94},{"prefix":"-moz-","added":22}],"firefox_android":[{"added":94},{"prefix":"-moz-","added":22}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}},"_aliasOf":"fit-content"},"max-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":94}],"chrome_android":[{"added":94}],"edge":[{"added":94}],"firefox":[{"added":66},{"prefix":"-moz-","added":22}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":22}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":94}],"chrome_android":[{"added":94}],"edge":[{"added":94}],"firefox":[{"added":66},{"prefix":"-moz-","added":22}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":22}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}},"_aliasOf":"min-content"},"_aliasOf":"flex-basis","-webkit-auto":{"_aliasOf":"auto"},"-moz-fit-content":{"_aliasOf":"fit-content"},"-moz-max-content":{"_aliasOf":"max-content"},"-moz-min-content":{"_aliasOf":"min-content"}},"flex-direction":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/flex-direction","spec_url":"https://drafts.csswg.org/css-flexbox/#flex-direction-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":21}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":81},{"partial_implementation":true,"added":20},{"prefix":"-webkit-","added":49}],"firefox_android":[{"added":81},{"partial_implementation":true,"added":20},{"prefix":"-webkit-","added":49}],"ie":[{"added":11},{"prefix":"-ms-","added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"flex-direction"},"flex-flow":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/flex-flow","spec_url":"https://drafts.csswg.org/css-flexbox/#flex-flow-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":21}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12}],"firefox":[{"added":28},{"prefix":"-webkit-","added":49}],"firefox_android":[{"added":28},{"prefix":"-webkit-","added":49}],"ie":[{"added":11}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"flex-flow"},"flex-grow":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/flex-grow","spec_url":"https://drafts.csswg.org/css-flexbox/#flex-grow-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":22}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":20}],"firefox_android":[{"added":20}],"ie":[{"added":11},{"alternative_name":"-ms-flex-positive","added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"less_than_zero_animate":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":49}],"chrome_android":[{"added":49}],"edge":[{"added":79}],"firefox":[{"added":32}],"firefox_android":[{"added":32}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"flex-grow"},"flex-shrink":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/flex-shrink","spec_url":"https://drafts.csswg.org/css-flexbox/#flex-shrink-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":22}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":20},{"prefix":"-webkit-","added":49}],"firefox_android":[{"added":20},{"prefix":"-webkit-","added":49}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":8}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":8}]}},"_aliasOf":"flex-shrink"},"flex-wrap":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/flex-wrap","spec_url":"https://drafts.csswg.org/css-flexbox/#flex-wrap-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":21}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12}],"firefox":[{"added":28}],"firefox_android":[{"added":52}],"ie":[{"partial_implementation":true,"added":11}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"flex-wrap"},"float":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/float","spec_url":["https://drafts.csswg.org/css2/#propdef-float","https://drafts.csswg.org/css-logical/#float-clear"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"flow_relative_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"flags":[{"name":"enable-experimental-web-platform-features","type":"preference","value_to_set":"enabled"}],"added":70}],"chrome_android":[{"flags":[{"name":"enable-experimental-web-platform-features","type":"preference","value_to_set":"enabled"}],"added":70}],"edge":[{"flags":[{"name":"enable-experimental-web-platform-features","type":"preference","value_to_set":"enabled"}],"added":79}],"firefox":[{"added":55}],"firefox_android":[{"added":55}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}}},"font":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font","spec_url":"https://drafts.csswg.org/css-fonts/#font-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":3}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"font_stretch_support":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":60}],"chrome_android":[{"added":60}],"edge":[{"added":79}],"firefox":[{"added":43}],"firefox_android":[{"added":43}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"system_fonts":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}}},"font-family":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-family","spec_url":["https://drafts.csswg.org/css-fonts/#generic-font-families","https://drafts.csswg.org/css-fonts/#font-family-prop"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":3}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"math":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":103}],"chrome_android":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":103}],"edge":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":103}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"system-ui":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":56}],"chrome_android":[{"added":56}],"edge":[{"added":79}],"firefox":[{"added":92},{"alternative_name":"-apple-system","added":43}],"firefox_android":[{"added":92}],"ie":[{"added":false}],"safari":[{"added":11},{"alternative_name":"-apple-system","added":9}],"safari_ios":[{"added":11},{"alternative_name":"-apple-system","added":9}]}},"_aliasOf":"system-ui"},"-apple-system":{"_aliasOf":"system-ui"}},"font-feature-settings":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-feature-settings","spec_url":"https://drafts.csswg.org/css-fonts/#font-feature-settings-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":48},{"prefix":"-webkit-","added":16}],"chrome_android":[{"added":48}],"edge":[{"added":15}],"firefox":[{"added":34},{"prefix":"-moz-","added":15}],"firefox_android":[{"added":34},{"prefix":"-moz-","added":15}],"ie":[{"added":10}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}},"_aliasOf":"font-feature-settings"},"font-kerning":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-kerning","spec_url":"https://drafts.csswg.org/css-fonts/#font-kerning-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":33},{"prefix":"-webkit-","added":29,"removed":33}],"chrome_android":[{"added":33},{"prefix":"-webkit-","added":29,"removed":33}],"edge":[{"added":79}],"firefox":[{"added":32}],"firefox_android":[{"added":32}],"ie":[{"added":false}],"safari":[{"added":9},{"prefix":"-webkit-","added":6}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":6}]}},"_aliasOf":"font-kerning"},"font-language-override":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-language-override","spec_url":"https://drafts.csswg.org/css-fonts/#font-language-override-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":34},{"prefix":"-moz-","added":4}],"firefox_android":[{"added":34},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"font-language-override"},"font-optical-sizing":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-optical-sizing","spec_url":"https://drafts.csswg.org/css-fonts/#font-optical-sizing-def","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":79}],"chrome_android":[{"added":79}],"edge":[{"added":17}],"firefox":[{"added":62}],"firefox_android":[{"added":62}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"font-palette":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-palette","spec_url":"https://drafts.csswg.org/css-fonts/#font-palette-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":101}],"chrome_android":[{"added":101}],"edge":[{"added":101}],"firefox":[{"added":107}],"firefox_android":[{"added":107}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"font-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-size","spec_url":"https://drafts.csswg.org/css-fonts/#font-size-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"math":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference"}],"impl_url":"https://crrev.com/c/2423843","added":87}],"chrome_android":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference"}],"impl_url":"https://crrev.com/c/2423843","added":87}],"edge":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference"}],"impl_url":"https://crrev.com/c/2423843","added":87}],"firefox":[{"impl_url":"https://bugzil.la/1667090","added":false}],"firefox_android":[{"impl_url":"https://bugzil.la/1667090","added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"rem_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":31}],"chrome_android":[{"added":42}],"edge":[{"added":12}],"firefox":[{"added":31}],"firefox_android":[{"added":31}],"ie":[{"added":9}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"xxx-large":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":79}],"chrome_android":[{"added":79}],"edge":[{"added":79}],"firefox":[{"added":70}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":16.4}],"safari_ios":[{"added":16.4}]}}}},"font-size-adjust":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-size-adjust","spec_url":"https://drafts.csswg.org/css-fonts-5/#font-size-adjust-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":3},{"partial_implementation":true,"added":1,"removed":3}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":16.4}],"safari_ios":[{"added":16.4}]}},"two-values":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-size-adjust","spec_url":"https://drafts.csswg.org/css-fonts-5/#font-size-adjust-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":92}],"firefox_android":[{"added":92}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"font-smooth":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-smooth","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"alternative_name":"-webkit-font-smoothing","added":5}],"chrome_android":[{"alternative_name":"-webkit-font-smoothing","added":18}],"edge":[{"alternative_name":"-webkit-font-smoothing","added":79}],"firefox":[{"alternative_name":"-moz-osx-font-smoothing","added":25}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"alternative_name":"-webkit-font-smoothing","added":4}],"safari_ios":[{"alternative_name":"-webkit-font-smoothing","added":3.2}]}},"_aliasOf":"font-smooth"},"font-stretch":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-stretch","spec_url":"https://drafts.csswg.org/css-fonts/#font-stretch-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":60}],"chrome_android":[{"added":60}],"edge":[{"added":12}],"firefox":[{"added":9}],"firefox_android":[{"added":9}],"ie":[{"added":9}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}},"percentage":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":62}],"chrome_android":[{"added":62}],"edge":[{"added":18}],"firefox":[{"added":61}],"firefox_android":[{"added":61}],"ie":[{"added":false}],"safari":[{"added":11.1}],"safari_ios":[{"added":11.3}]}}}},"font-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-style","spec_url":"https://drafts.csswg.org/css-fonts/#font-style-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"oblique-angle":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":61}],"firefox_android":[{"added":61}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}}},"font-synthesis":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-synthesis","spec_url":"https://drafts.csswg.org/css-fonts/#font-synthesis","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":97}],"chrome_android":[{"added":97}],"edge":[{"added":97}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}},"small-caps":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":97}],"chrome_android":[{"added":97}],"edge":[{"added":97}],"firefox":[{"added":93}],"firefox_android":[{"added":93}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}}},"font-synthesis-small-caps":{"__compat":{"spec_url":"https://drafts.csswg.org/css-fonts/#font-synthesis-small-caps","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":97}],"chrome_android":[{"added":97}],"edge":[{"added":97}],"firefox":[{"added":111}],"firefox_android":[{"added":111}],"ie":[{"added":false}],"safari":[{"added":16.4}],"safari_ios":[{"added":16.4}]}}},"font-synthesis-style":{"__compat":{"spec_url":"https://drafts.csswg.org/css-fonts/#font-synthesis-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":97}],"chrome_android":[{"added":97}],"edge":[{"added":97}],"firefox":[{"added":111}],"firefox_android":[{"added":111}],"ie":[{"added":false}],"safari":[{"added":16.4}],"safari_ios":[{"added":16.4}]}}},"font-synthesis-weight":{"__compat":{"spec_url":"https://drafts.csswg.org/css-fonts/#font-synthesis-weight","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":97}],"chrome_android":[{"added":97}],"edge":[{"added":97}],"firefox":[{"added":111}],"firefox_android":[{"added":111}],"ie":[{"added":false}],"safari":[{"added":16.4}],"safari_ios":[{"added":16.4}]}}},"font-variant":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant","spec_url":"https://drafts.csswg.org/css-fonts/#font-variant-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"css_fonts_shorthand":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":52}],"chrome_android":[{"added":52}],"edge":[{"added":79}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"font-variant-emoji":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-emoji","spec_url":"https://drafts.csswg.org/css-fonts/#font-variant-emoji-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":108}],"firefox_android":[{"added":108}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"greek_accented_characters":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"turkic_is":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":31}],"chrome_android":[{"added":31}],"edge":[{"added":12}],"firefox":[{"added":14}],"firefox_android":[{"added":14}],"ie":[{"added":4}],"safari":[{"added":8}],"safari_ios":[{"added":8}]}}},"uppercase_eszett":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"font-variant-alternates":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-alternates","spec_url":"https://drafts.csswg.org/css-fonts/#font-variant-alternates-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":111}],"chrome_android":[{"added":111}],"edge":[{"added":111}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}},"annotation":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-alternates#annotation()","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":111}],"chrome_android":[{"added":111}],"edge":[{"added":111}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":16.2}],"safari_ios":[{"added":16.2}]}}},"character_variant":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-alternates#character-variant()","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":111}],"chrome_android":[{"added":111}],"edge":[{"added":111}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":16.2}],"safari_ios":[{"added":16.2}]}}},"ornaments":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-alternates#ornaments()","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":111}],"chrome_android":[{"added":111}],"edge":[{"added":111}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":16.2}],"safari_ios":[{"added":16.2}]}}},"styleset":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-alternates#styleset()","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":111}],"chrome_android":[{"added":111}],"edge":[{"added":111}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":16.2}],"safari_ios":[{"added":16.2}]}}},"stylistic":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-alternates#stylistic()","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":111}],"chrome_android":[{"added":111}],"edge":[{"added":111}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":16.2}],"safari_ios":[{"added":16.2}]}}},"swash":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-alternates#swash()","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":111}],"chrome_android":[{"added":111}],"edge":[{"added":111}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":16.2}],"safari_ios":[{"added":16.2}]}}}},"font-variant-caps":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-caps","spec_url":"https://drafts.csswg.org/css-fonts/#font-variant-caps-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":52}],"chrome_android":[{"added":52}],"edge":[{"added":79}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"font-variant-east-asian":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-east-asian","spec_url":"https://drafts.csswg.org/css-fonts/#font-variant-east-asian-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":63}],"chrome_android":[{"added":63}],"edge":[{"added":79}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"font-variant-emoji":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-emoji","spec_url":"https://drafts.csswg.org/css-fonts/#font-variant-emoji-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":108}],"firefox_android":[{"added":108}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"font-variant-ligatures":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-ligatures","spec_url":"https://drafts.csswg.org/css-fonts/#font-variant-ligatures-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":34},{"prefix":"-webkit-","added":31}],"chrome_android":[{"added":34}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":9.1},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9.3},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"font-variant-ligatures"},"font-variant-numeric":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-numeric","spec_url":"https://drafts.csswg.org/css-fonts/#font-variant-numeric-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":52}],"chrome_android":[{"added":52}],"edge":[{"added":79}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"font-variant-position":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variant-position","spec_url":"https://drafts.csswg.org/css-fonts/#font-variant-position-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":34}],"firefox_android":[{"added":34}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"font-variation-settings":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-variation-settings","spec_url":"https://drafts.csswg.org/css-fonts/#font-variation-settings-def","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":62}],"chrome_android":[{"added":62}],"edge":[{"added":17}],"firefox":[{"added":62}],"firefox_android":[{"added":62}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"font-weight":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/font-weight","spec_url":"https://drafts.csswg.org/css-fonts/#font-weight-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":2}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":3}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"number":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":62}],"chrome_android":[{"added":62}],"edge":[{"added":17}],"firefox":[{"added":61}],"firefox_android":[{"added":61}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}}},"forced-color-adjust":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/forced-color-adjust","spec_url":"https://drafts.csswg.org/css-color-adjust/#forced-color-adjust-prop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":89}],"chrome_android":[{"added":89}],"edge":[{"added":79},{"alternative_name":"-ms-high-contrast-adjust","added":12}],"firefox":[{"added":113}],"firefox_android":[{"added":113}],"ie":[{"alternative_name":"-ms-high-contrast-adjust","added":10}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"forced-color-adjust"},"gap":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/gap","spec_url":"https://drafts.csswg.org/css-align/#gap-shorthand","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"flex_context":{"__compat":{"spec_url":"https://drafts.csswg.org/css-align/#gap-shorthand","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":84}],"chrome_android":[{"added":84}],"edge":[{"added":84}],"firefox":[{"added":63}],"firefox_android":[{"added":63}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"grid_context":{"__compat":{"spec_url":"https://drafts.csswg.org/css-align/#gap-shorthand","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":66},{"alternative_name":"grid-gap","added":57}],"chrome_android":[{"added":66},{"alternative_name":"grid-gap","added":57}],"edge":[{"added":16},{"alternative_name":"grid-gap","added":16}],"firefox":[{"added":61},{"alternative_name":"grid-gap","added":52}],"firefox_android":[{"added":61},{"alternative_name":"grid-gap","added":52}],"ie":[{"added":false}],"safari":[{"added":12},{"alternative_name":"grid-gap","added":10.1}],"safari_ios":[{"added":12},{"alternative_name":"grid-gap","added":10.3}]}},"calc_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":66}],"chrome_android":[{"added":66}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"percentage_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":66}],"chrome_android":[{"added":66}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"_aliasOf":"grid_context"},"multicol_context":{"__compat":{"spec_url":"https://drafts.csswg.org/css-align/#gap-shorthand","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":66}],"chrome_android":[{"added":66}],"edge":[{"added":16}],"firefox":[{"added":61}],"firefox_android":[{"added":61}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"grid-gap":{"_aliasOf":"grid_context"}},"grid":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid","spec_url":"https://drafts.csswg.org/css-grid/#grid-shorthand","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"grid-area":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-area","spec_url":"https://drafts.csswg.org/css-grid/#propdef-grid-area","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"grid-auto-columns":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-auto-columns","spec_url":"https://drafts.csswg.org/css-grid/#auto-tracks","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16},{"alternative_name":"-ms-grid-columns","added":12,"removed":79}],"firefox":[{"added":70},{"partial_implementation":true,"added":52,"removed":70}],"firefox_android":[{"added":79},{"partial_implementation":true,"added":52,"removed":79}],"ie":[{"alternative_name":"-ms-grid-columns","added":10}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"_aliasOf":"grid-auto-columns"},"grid-auto-flow":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-auto-flow","spec_url":"https://drafts.csswg.org/css-grid/#grid-auto-flow-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"grid-auto-rows":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-auto-rows","spec_url":"https://drafts.csswg.org/css-grid/#auto-tracks","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16},{"alternative_name":"-ms-grid-rows","added":12,"removed":79}],"firefox":[{"added":70},{"partial_implementation":true,"added":52,"removed":70}],"firefox_android":[{"added":79},{"partial_implementation":true,"added":52,"removed":79}],"ie":[{"alternative_name":"-ms-grid-rows","added":10}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"_aliasOf":"grid-auto-rows"},"grid-column":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-column","spec_url":"https://drafts.csswg.org/css-grid/#placement-shorthands","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"grid-column-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-column-end","spec_url":"https://drafts.csswg.org/css-grid/#line-placement","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"grid-column-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-column-start","spec_url":"https://drafts.csswg.org/css-grid/#line-placement","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"grid-row":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-row","spec_url":"https://drafts.csswg.org/css-grid/#placement-shorthands","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"grid-row-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-row-end","spec_url":"https://drafts.csswg.org/css-grid/#line-placement","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"grid-row-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-row-start","spec_url":"https://drafts.csswg.org/css-grid/#line-placement","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"grid-template":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-template","spec_url":"https://drafts.csswg.org/css-grid/#explicit-grid-shorthand","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"grid-template-areas":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-template-areas","spec_url":"https://drafts.csswg.org/css-grid/#grid-template-areas-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"grid-template-columns":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-template-columns","spec_url":["https://drafts.csswg.org/css-grid/#track-sizing","https://drafts.csswg.org/css-grid/#subgrids","https://drafts.csswg.org/css-grid-3/#masonry-layout"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16},{"alternative_name":"-ms-grid-columns","added":12,"removed":79}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"alternative_name":"-ms-grid-columns","added":10}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"animation":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":107}],"chrome_android":[{"added":107}],"edge":[{"added":107}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"fit-content":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/fit-content","spec_url":"https://drafts.csswg.org/css-sizing-4/#sizing-values","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"masonry":{"__compat":{"spec_url":"https://drafts.csswg.org/css-grid-3/#masonry-layout","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.grid-template-masonry-value.enabled","type":"preference","value_to_set":"true"}],"added":77}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"minmax":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/minmax()","spec_url":"https://drafts.csswg.org/css-grid/#valdef-grid-template-columns-minmax","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"repeat":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/repeat()","spec_url":"https://drafts.csswg.org/css-grid/#repeat-notation","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":76},{"partial_implementation":true,"added":57,"removed":76},{"partial_implementation":true,"added":52,"removed":57}],"firefox_android":[{"added":79},{"partial_implementation":true,"added":57,"removed":79},{"partial_implementation":true,"added":52,"removed":57}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"subgrid":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/CSS_Grid_Layout/Subgrid","spec_url":"https://drafts.csswg.org/css-grid/#subgrids","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":71}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"_aliasOf":"grid-template-columns"},"grid-template-rows":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/grid-template-rows","spec_url":["https://drafts.csswg.org/css-grid/#track-sizing","https://drafts.csswg.org/css-grid/#subgrids","https://drafts.csswg.org/css-grid-3/#masonry-layout"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16},{"alternative_name":"-ms-grid-rows","added":12,"removed":79}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"alternative_name":"-ms-grid-rows","added":10}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"animation":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":107}],"chrome_android":[{"added":107}],"edge":[{"added":107}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"fit-content":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/fit-content","spec_url":"https://drafts.csswg.org/css-sizing-4/#sizing-values","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"masonry":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/CSS_Grid_Layout/Masonry_Layout","spec_url":"https://drafts.csswg.org/css-grid-3/#masonry-layout","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.grid-template-masonry-value.enabled","type":"preference","value_to_set":"true"}],"added":77}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"minmax":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/minmax()","spec_url":"https://drafts.csswg.org/css-grid/#valdef-grid-template-columns-minmax","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"repeat":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/repeat()","spec_url":"https://drafts.csswg.org/css-grid/#repeat-notation","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":76},{"partial_implementation":true,"added":57,"removed":76},{"partial_implementation":true,"added":52,"removed":57}],"firefox_android":[{"added":79},{"partial_implementation":true,"added":57,"removed":79},{"partial_implementation":true,"added":52,"removed":57}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"subgrid":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/CSS_Grid_Layout/Subgrid","spec_url":"https://drafts.csswg.org/css-grid/#subgrids","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":71}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"_aliasOf":"grid-template-rows"},"hanging-punctuation":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/hanging-punctuation","spec_url":"https://drafts.csswg.org/css-text/#hanging-punctuation-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"partial_implementation":true,"added":10}],"safari_ios":[{"partial_implementation":true,"added":10}]}}},"height":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/height","spec_url":"https://drafts.csswg.org/css-sizing/#preferred-size-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"fit-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46}],"chrome_android":[{"added":46}],"edge":[{"added":79}],"firefox":[{"added":94}],"firefox_android":[{"added":94}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":9}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":9}]}},"_aliasOf":"fit-content"},"fit-content_function":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.fit-content-function.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"max-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46}],"chrome_android":[{"added":46}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46}],"chrome_android":[{"added":46}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}},"_aliasOf":"min-content"},"stretch":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-fill-available","added":28}],"chrome_android":[{"alternative_name":"-webkit-fill-available","added":28}],"edge":[{"alternative_name":"-webkit-fill-available","added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"alternative_name":"-webkit-fill-available","added":9}],"safari_ios":[{"alternative_name":"-webkit-fill-available","added":9}]}},"_aliasOf":"stretch"},"-webkit-fit-content":{"_aliasOf":"fit-content"},"-moz-max-content":{"_aliasOf":"max-content"},"-moz-min-content":{"_aliasOf":"min-content"},"-webkit-fill-available":{"_aliasOf":"stretch"}},"hyphenate-character":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/hyphenate-character","spec_url":"https://drafts.csswg.org/css-text-4/#propdef-hyphenate-character","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":106},{"prefix":"-webkit-","added":6}],"chrome_android":[{"added":106},{"prefix":"-webkit-","added":18}],"edge":[{"added":106},{"prefix":"-webkit-","added":79}],"firefox":[{"added":98}],"firefox_android":[{"added":98}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":5.1}],"safari_ios":[{"prefix":"-webkit-","added":5}]}},"_aliasOf":"hyphenate-character"},"hyphenate-limit-chars":{"__compat":{"spec_url":"https://drafts.csswg.org/css-text-4/#propdef-hyphenate-limit-chars","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":109}],"chrome_android":[{"added":109}],"edge":[{"added":109}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"hyphens":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/hyphens","spec_url":"https://drafts.csswg.org/css-text/#hyphens-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":55},{"prefix":"-webkit-","added":13}],"chrome_android":[{"added":55},{"prefix":"-webkit-","added":18}],"edge":[{"added":79},{"prefix":"-webkit-","added":79},{"partial_implementation":true,"prefix":"-ms-","added":12,"removed":79}],"firefox":[{"added":43},{"prefix":"-moz-","added":6}],"firefox_android":[{"added":43},{"prefix":"-moz-","added":6}],"ie":[{"partial_implementation":true,"prefix":"-ms-","added":10}],"safari":[{"prefix":"-webkit-","added":5.1}],"safari_ios":[{"prefix":"-webkit-","added":4.2}]}},"auto_value":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":88},{"partial_implementation":true,"added":55}],"chrome_android":[{"added":55}],"edge":[{"added":88},{"partial_implementation":true,"added":79}],"firefox":[{"added":6}],"firefox_android":[{"added":6}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":4.2}]}}},"language_afrikaans":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_bosnian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_bulgarian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_catalan":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"language_croatian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"language_czech":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"language_danish":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"language_dutch":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"language_english":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":55}],"chrome_android":[{"added":55}],"edge":[{"added":12}],"firefox":[{"added":6}],"firefox_android":[{"added":6}],"ie":[{"added":10}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"language_esperanto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_estonian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_finish":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"language_french":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"language_galician":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":9}],"firefox_android":[{"added":9}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_german_reformed_orthography":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"language_german_swiss_orthography":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_german_traditional_orthography":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_hungarian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":9}],"firefox_android":[{"added":9}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"language_icelandic":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_interlingua":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_italian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":9}],"firefox_android":[{"added":9}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"language_kurmanji":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_latin":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_lithuanian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_mongolian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_norwegian_nn":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":5}]}}},"language_norwegian_no":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"language_polish":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":31}],"firefox_android":[{"added":31}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"language_portuguese":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"language_portuguese_brazilian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_russian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"language_slovenian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_spanish":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"language_swedish":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"language_turkish":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":9}],"firefox_android":[{"added":9}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"language_ukrainian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":9}],"firefox_android":[{"added":9}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"language_upper_sorbian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"language_welsh":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":8}],"firefox_android":[{"added":8}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"hyphens"},"image-orientation":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/image-orientation","spec_url":"https://drafts.csswg.org/css-images/#the-image-orientation","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":81}],"chrome_android":[{"added":81}],"edge":[{"added":81}],"firefox":[{"added":26}],"firefox_android":[{"added":26}],"ie":[{"added":false}],"safari":[{"added":13.1}],"safari_ios":[{"added":13.4}]}},"flip_and_angle":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":26,"removed":63}],"firefox_android":[{"added":26,"removed":63}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"image-rendering":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/image-rendering","spec_url":"https://drafts.csswg.org/css-images/#the-image-rendering","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":13}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":6}],"safari_ios":[{"added":6}]}},"crisp-edges":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-optimize-contrast","added":13}],"chrome_android":[{"alternative_name":"-webkit-optimize-contrast","added":18}],"edge":[{"alternative_name":"-webkit-optimize-contrast","added":79}],"firefox":[{"added":65},{"prefix":"-moz-","added":3.6}],"firefox_android":[{"added":65},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":7},{"alternative_name":"-webkit-optimize-contrast","added":6}],"safari_ios":[{"added":7},{"alternative_name":"-webkit-optimize-contrast","added":6}]}},"_aliasOf":"crisp-edges"},"optimizeQuality":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":28}],"chrome_android":[{"added":28}],"edge":[{"added":79}],"firefox":[{"added":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"optimizeSpeed":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":28}],"chrome_android":[{"added":28}],"edge":[{"added":79}],"firefox":[{"added":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"pixelated":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":41}],"chrome_android":[{"added":41}],"edge":[{"added":79}],"firefox":[{"added":93}],"firefox_android":[{"added":93}],"ie":[{"added":false}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}}},"-webkit-optimize-contrast":{"_aliasOf":"crisp-edges"},"-moz-crisp-edges":{"_aliasOf":"crisp-edges"}},"ime-mode":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/ime-mode","spec_url":"https://drafts.csswg.org/css-ui/#input-method-editor","status":{"deprecated":true,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79},{"prefix":"-ms-","added":12,"removed":79}],"firefox":[{"added":3}],"firefox_android":[{"added":4}],"ie":[{"added":5},{"prefix":"-ms-","added":8}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"ime-mode"},"initial-letter":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/initial-letter","spec_url":"https://drafts.csswg.org/css-inline/#sizing-drop-initials","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":110}],"chrome_android":[{"added":110}],"edge":[{"added":110}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":9}],"safari_ios":[{"prefix":"-webkit-","added":9}]}},"_aliasOf":"initial-letter"},"initial-letter-align":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/initial-letter-align","spec_url":"https://drafts.csswg.org/css-inline/#aligning-initial-letter","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"inline-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/inline-size","spec_url":"https://drafts.csswg.org/css-logical/#dimension-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"fit-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":94},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":94},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"fit-content"},"fit-content_function":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.fit-content-function.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"max-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"min-content"},"-moz-fit-content":{"_aliasOf":"fit-content"},"-webkit-fill-available":{"_aliasOf":"fit-content"},"-moz-max-content":{"_aliasOf":"max-content"},"-moz-min-content":{"_aliasOf":"min-content"}},"inset":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/inset","spec_url":"https://drafts.csswg.org/css-logical/#propdef-inset","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"inset-block":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/inset-block","spec_url":"https://drafts.csswg.org/css-logical/#propdef-inset-block","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":63},{"alternative_name":"offset-block","added":41,"removed":63}],"firefox_android":[{"added":63},{"alternative_name":"offset-block","added":41,"removed":63}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}},"_aliasOf":"inset-block"},"inset-block-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/inset-block-end","spec_url":"https://drafts.csswg.org/css-logical/#position-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":63},{"alternative_name":"offset-block-end","added":41,"removed":63}],"firefox_android":[{"added":63},{"alternative_name":"offset-block-end","added":41,"removed":63}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}},"_aliasOf":"inset-block-end"},"inset-block-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/inset-block-start","spec_url":"https://drafts.csswg.org/css-logical/#position-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":63},{"alternative_name":"offset-block-start","added":41,"removed":63}],"firefox_android":[{"added":63},{"alternative_name":"offset-block-start","added":41,"removed":63}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}},"_aliasOf":"inset-block-start"},"inset-inline":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/inset-inline","spec_url":"https://drafts.csswg.org/css-logical/#propdef-inset-inline","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":63},{"alternative_name":"offset-inline","added":41,"removed":63}],"firefox_android":[{"added":63},{"alternative_name":"offset-inline","added":41,"removed":63}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}},"_aliasOf":"inset-inline"},"inset-inline-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/inset-inline-end","spec_url":"https://drafts.csswg.org/css-logical/#position-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":63},{"alternative_name":"offset-inline-end","added":41,"removed":63}],"firefox_android":[{"added":63},{"alternative_name":"offset-inline-end","added":41,"removed":63}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}},"_aliasOf":"inset-inline-end"},"inset-inline-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/inset-inline-start","spec_url":"https://drafts.csswg.org/css-logical/#position-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":63},{"alternative_name":"offset-inline-start","added":41,"removed":63}],"firefox_android":[{"added":63},{"alternative_name":"offset-inline-start","added":41,"removed":63}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}},"_aliasOf":"inset-inline-start"},"isolation":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/isolation","spec_url":"https://drafts.fxtf.org/compositing/#isolation","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":41}],"chrome_android":[{"added":41}],"edge":[{"added":79}],"firefox":[{"added":36}],"firefox_android":[{"added":36}],"ie":[{"added":false}],"safari":[{"added":8}],"safari_ios":[{"added":8}]}}},"justify-content":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/justify-content","spec_url":["https://drafts.csswg.org/css-align/#align-justify-content","https://drafts.csswg.org/css-flexbox/#justify-content-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":21}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":20},{"prefix":"-webkit-","added":49}],"firefox_android":[{"added":20},{"prefix":"-webkit-","added":49}],"ie":[{"added":11}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"flex_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-align/#align-justify-content","https://drafts.csswg.org/css-flexbox/#justify-content-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":52},{"partial_implementation":true,"added":21,"removed":52}],"chrome_android":[{"added":52},{"partial_implementation":true,"added":25,"removed":52}],"edge":[{"added":12}],"firefox":[{"added":20}],"firefox_android":[{"added":20}],"ie":[{"added":11}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}},"left_right":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":93}],"chrome_android":[{"added":93}],"edge":[{"added":93}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}}},"safe_unsafe":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":63}],"firefox_android":[{"added":63}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"space-evenly":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":60}],"chrome_android":[{"added":60}],"edge":[{"added":79}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"start_end":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":93}],"chrome_android":[{"added":93}],"edge":[{"added":93}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"stretch":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}}}},"grid_context":{"__compat":{"spec_url":["https://drafts.csswg.org/css-align/#align-justify-content","https://drafts.csswg.org/css-flexbox/#justify-content-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":52}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"_aliasOf":"justify-content"},"justify-items":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/justify-items","spec_url":"https://drafts.csswg.org/css-align/#justify-items-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":52}],"chrome_android":[{"added":52}],"edge":[{"added":12}],"firefox":[{"added":20}],"firefox_android":[{"added":20}],"ie":[{"added":11}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}},"flex_context":{"__compat":{"spec_url":"https://drafts.csswg.org/css-align/#justify-items-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":52}],"chrome_android":[{"added":52}],"edge":[{"added":12}],"firefox":[{"added":20}],"firefox_android":[{"added":20}],"ie":[{"added":11}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}}},"grid_context":{"__compat":{"spec_url":"https://drafts.csswg.org/css-align/#justify-items-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}}},"justify-self":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/justify-self","spec_url":"https://drafts.csswg.org/css-align/#justify-self-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":10}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"flex_context":{"__compat":{"spec_url":"https://drafts.csswg.org/css-align/#justify-self-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"grid_context":{"__compat":{"spec_url":"https://drafts.csswg.org/css-align/#justify-self-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":16}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"partial_implementation":true,"prefix":"-ms-","added":10}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"_aliasOf":"grid_context"},"-ms-grid_context":{"_aliasOf":"grid_context"}},"justify-tracks":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/justify-tracks","spec_url":"https://drafts.csswg.org/css-grid-3/#tracks-alignment","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.grid-template-masonry-value.enabled","type":"preference","value_to_set":"true"}],"added":77}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"left":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/left","spec_url":"https://drafts.csswg.org/css-position/#insets","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"letter-spacing":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/letter-spacing","spec_url":"https://drafts.csswg.org/css-text/#letter-spacing-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"svg":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":72}],"firefox_android":[{"added":false}],"ie":[{"added":9}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}}},"line-break":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/line-break","spec_url":"https://drafts.csswg.org/css-text/#line-break-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":58},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":58},{"prefix":"-webkit-","added":18}],"edge":[{"added":14}],"firefox":[{"added":69}],"firefox_android":[{"added":79}],"ie":[{"added":5.5},{"prefix":"-ms-","added":8}],"safari":[{"added":11},{"prefix":"-webkit-","added":3},{"prefix":"-khtml-","added":2,"removed":3}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":1}]}},"_aliasOf":"line-break"},"line-height":{"-moz-block-height":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/line-height","spec_url":"https://drafts.csswg.org/css-inline/#line-height-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"line-height-step":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/line-height-step","spec_url":"https://drafts.csswg.org/css-rhythm/#line-height-step","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"flags":[{"name":"--enable-blink-features=CSSSnapSize","type":"runtime_flag"}],"added":60}],"chrome_android":[{"flags":[{"name":"--enable-blink-features=CSSSnapSize","type":"runtime_flag"}],"added":60}],"edge":[{"flags":[{"name":"--enable-blink-features=CSSSnapSize","type":"runtime_flag"}],"added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"list-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/list-style","spec_url":"https://drafts.csswg.org/css-lists/#list-style-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"symbols":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/symbols()","spec_url":"https://drafts.csswg.org/css-counter-styles/#symbols-function","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91}],"chrome_android":[{"added":91}],"edge":[{"added":91}],"firefox":[{"added":35}],"firefox_android":[{"added":35}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"list-style-image":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/list-style-image","spec_url":"https://drafts.csswg.org/css-lists/#image-markers","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"list-style-position":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/list-style-position","spec_url":"https://drafts.csswg.org/css-lists/#list-style-position-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"list-style-type":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/list-style-type","spec_url":["https://drafts.csswg.org/css-lists/#text-markers","https://drafts.csswg.org/css-counter-styles/#extending-css2"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"afar":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"amharic":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"amharic-abegede":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"arabic-indic":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"arabic-indic"},"armenian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"asterisks":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":13,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"bengali":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"bengali"},"binary":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":13,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"cambodian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33}],"firefox_android":[{"added":33}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"circle":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"cjk-decimal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91}],"chrome_android":[{"added":91}],"edge":[{"added":91}],"firefox":[{"added":28}],"firefox_android":[{"added":28}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"cjk-earthly-branch":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"cjk-earthly-branch"},"cjk-heavenly-stem":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"cjk-heavenly-stem"},"cjk-ideographic":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"decimal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"decimal-leading-zero":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"devanagari":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"devanagari"},"disc":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"disclosure-closed":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":89}],"chrome_android":[{"added":89}],"edge":[{"added":89}],"firefox":[{"added":33}],"firefox_android":[{"added":33}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"disclosure-open":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":89}],"chrome_android":[{"added":89}],"edge":[{"added":89}],"firefox":[{"added":33}],"firefox_android":[{"added":33}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"ethiopic":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-abegede":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-abegede-am-et":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-abegede-gez":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-abegede-ti-er":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-abegede-ti-et":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-halehame":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":45}],"chrome_android":[{"added":45}],"edge":[{"added":79}],"firefox":[{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"ethiopic-halehame"},"ethiopic-halehame-aa-er":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-halehame-aa-et":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-halehame-am":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":45}],"chrome_android":[{"added":45}],"edge":[{"added":79}],"firefox":[{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"ethiopic-halehame-am"},"ethiopic-halehame-am-et":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-halehame-gez":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-halehame-om-et":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-halehame-sid-et":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-halehame-so-et":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-halehame-ti-er":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"ethiopic-halehame-ti-er"},"ethiopic-halehame-ti-et":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"ethiopic-halehame-ti-et"},"ethiopic-halehame-tig":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"ethiopic-numeric":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91}],"chrome_android":[{"added":91}],"edge":[{"added":91}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}},"_aliasOf":"ethiopic-numeric"},"footnotes":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":13,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"georgian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"gujarati":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"gujarati"},"gurmukhi":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"gurmukhi"},"hangul":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"hangul"},"hangul-consonant":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"hangul-consonant"},"hebrew":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"hiragana":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"hiragana-iroha":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"japanese-formal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91}],"chrome_android":[{"added":91}],"edge":[{"added":91}],"firefox":[{"added":28},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":28},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}},"_aliasOf":"japanese-formal"},"japanese-informal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91}],"chrome_android":[{"added":91}],"edge":[{"added":91}],"firefox":[{"added":28},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":28},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}},"_aliasOf":"japanese-informal"},"kannada":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"kannada"},"katakana":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"katakana-iroha":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"khmer":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"khmer"},"korean-hangul-formal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":45}],"chrome_android":[{"added":45}],"edge":[{"added":79}],"firefox":[{"added":28}],"firefox_android":[{"added":28}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"korean-hanja-formal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":45}],"chrome_android":[{"added":45}],"edge":[{"added":79}],"firefox":[{"added":28}],"firefox_android":[{"added":28}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"korean-hanja-informal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":45}],"chrome_android":[{"added":45}],"edge":[{"added":79}],"firefox":[{"added":28}],"firefox_android":[{"added":28}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"lao":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"lao"},"lower-alpha":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"lower-armenian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":13}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33}],"firefox_android":[{"added":33}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"lower-greek":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"lower-hexadecimal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"lower-latin":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"lower-norwegian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"lower-roman":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"malayalam":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"malayalam"},"mongolian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33}],"firefox_android":[{"added":33}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"myanmar":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"myanmar"},"octal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"oriya":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"oriya"},"oromo":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"persian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"persian"},"sidama":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"simp-chinese-formal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":45}],"chrome_android":[{"added":45}],"edge":[{"added":79}],"firefox":[{"added":28},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":28},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}},"_aliasOf":"simp-chinese-formal"},"simp-chinese-informal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":45}],"chrome_android":[{"added":45}],"edge":[{"added":79}],"firefox":[{"added":28},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":28},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}},"_aliasOf":"simp-chinese-informal"},"somali":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"square":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"string":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":79}],"chrome_android":[{"added":79}],"edge":[{"added":79}],"firefox":[{"added":39}],"firefox_android":[{"added":39}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"symbols":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/symbols()","spec_url":"https://drafts.csswg.org/css-counter-styles/#symbols-function","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91}],"chrome_android":[{"added":91}],"edge":[{"added":91}],"firefox":[{"added":35}],"firefox_android":[{"added":35}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"tamil":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91}],"chrome_android":[{"added":91}],"edge":[{"added":91}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}},"_aliasOf":"tamil"},"telugu":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"telugu"},"thai":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":33},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"thai"},"tibetan":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"tigre":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"tigrinya-er":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"tigrinya-er-abegede":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"tigrinya-et":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"tigrinya-et-abegede":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"trad-chinese-formal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":45}],"chrome_android":[{"added":45}],"edge":[{"added":79}],"firefox":[{"added":28},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":28},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}},"_aliasOf":"trad-chinese-formal"},"trad-chinese-informal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":45}],"chrome_android":[{"added":45}],"edge":[{"added":79}],"firefox":[{"added":28},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":28},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}},"_aliasOf":"trad-chinese-informal"},"upper-alpha":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"upper-armenian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":13}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":33}],"firefox_android":[{"added":33}],"ie":[{"added":false}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}},"upper-greek":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"upper-hexadecimal":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"upper-latin":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"upper-norwegian":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":91},{"added":6,"removed":45}],"chrome_android":[{"added":91},{"added":18,"removed":45}],"edge":[{"added":91}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"upper-roman":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"urdu":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":6}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"prefix":"-moz-","added":33}],"firefox_android":[{"prefix":"-moz-","added":33}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"_aliasOf":"urdu"},"-moz-arabic-indic":{"_aliasOf":"arabic-indic"},"-moz-bengali":{"_aliasOf":"bengali"},"-moz-cjk-earthly-branch":{"_aliasOf":"cjk-earthly-branch"},"-moz-cjk-heavenly-stem":{"_aliasOf":"cjk-heavenly-stem"},"-moz-devanagari":{"_aliasOf":"devanagari"},"-moz-ethiopic-halehame":{"_aliasOf":"ethiopic-halehame"},"-moz-ethiopic-halehame-am":{"_aliasOf":"ethiopic-halehame-am"},"-moz-ethiopic-halehame-ti-er":{"_aliasOf":"ethiopic-halehame-ti-er"},"-moz-ethiopic-halehame-ti-et":{"_aliasOf":"ethiopic-halehame-ti-et"},"-moz-ethiopic-numeric":{"_aliasOf":"ethiopic-numeric"},"-moz-gujarati":{"_aliasOf":"gujarati"},"-moz-gurmukhi":{"_aliasOf":"gurmukhi"},"-moz-hangul":{"_aliasOf":"hangul"},"-moz-hangul-consonant":{"_aliasOf":"hangul-consonant"},"-moz-japanese-formal":{"_aliasOf":"japanese-formal"},"-moz-japanese-informal":{"_aliasOf":"japanese-informal"},"-moz-kannada":{"_aliasOf":"kannada"},"-moz-khmer":{"_aliasOf":"khmer"},"-moz-lao":{"_aliasOf":"lao"},"-moz-malayalam":{"_aliasOf":"malayalam"},"-moz-myanmar":{"_aliasOf":"myanmar"},"-moz-oriya":{"_aliasOf":"oriya"},"-moz-persian":{"_aliasOf":"persian"},"-moz-simp-chinese-formal":{"_aliasOf":"simp-chinese-formal"},"-moz-simp-chinese-informal":{"_aliasOf":"simp-chinese-informal"},"-moz-tamil":{"_aliasOf":"tamil"},"-moz-telugu":{"_aliasOf":"telugu"},"-moz-thai":{"_aliasOf":"thai"},"-moz-trad-chinese-formal":{"_aliasOf":"trad-chinese-formal"},"-moz-trad-chinese-informal":{"_aliasOf":"trad-chinese-informal"},"-moz-urdu":{"_aliasOf":"urdu"}},"margin":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/margin","spec_url":"https://drafts.csswg.org/css-box/#margin","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":3}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":6}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}}},"margin-block":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/margin-block","spec_url":"https://drafts.csswg.org/css-logical/#propdef-margin-block","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"margin-block-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/margin-block-end","spec_url":"https://drafts.csswg.org/css-logical/#margin-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"margin-block-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/margin-block-start","spec_url":"https://drafts.csswg.org/css-logical/#margin-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"margin-bottom":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/margin-bottom","spec_url":"https://drafts.csswg.org/css-box/#margin-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":3}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":6}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}}},"margin-inline":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/margin-inline","spec_url":"https://drafts.csswg.org/css-logical/#propdef-margin-inline","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"margin-inline-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/margin-inline-end","spec_url":"https://drafts.csswg.org/css-logical/#margin-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87},{"alternative_name":"-webkit-margin-end","added":2}],"chrome_android":[{"added":87},{"alternative_name":"-webkit-margin-end","added":18}],"edge":[{"added":87},{"alternative_name":"-webkit-margin-end","added":79}],"firefox":[{"added":41},{"alternative_name":"-moz-margin-end","added":3}],"firefox_android":[{"added":41},{"alternative_name":"-moz-margin-end","added":4}],"ie":[{"added":false}],"safari":[{"added":12.1},{"alternative_name":"-webkit-margin-end","added":3}],"safari_ios":[{"added":12.2},{"alternative_name":"-webkit-margin-end","added":3}]}},"_aliasOf":"margin-inline-end"},"margin-inline-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/margin-inline-start","spec_url":"https://drafts.csswg.org/css-logical/#margin-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87},{"alternative_name":"-webkit-margin-start","added":2}],"chrome_android":[{"added":87},{"alternative_name":"-webkit-margin-start","added":18}],"edge":[{"added":87},{"alternative_name":"-webkit-margin-start","added":79}],"firefox":[{"added":41},{"alternative_name":"-moz-margin-start","added":3}],"firefox_android":[{"added":41},{"alternative_name":"-moz-margin-start","added":4}],"ie":[{"added":false}],"safari":[{"added":12.1},{"alternative_name":"-webkit-margin-start","added":3}],"safari_ios":[{"added":12.2},{"alternative_name":"-webkit-margin-start","added":3}]}},"_aliasOf":"margin-inline-start"},"margin-left":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/margin-left","spec_url":"https://drafts.csswg.org/css-box/#margin-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":3}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":6}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}}},"margin-right":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/margin-right","spec_url":"https://drafts.csswg.org/css-box/#margin-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":3}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":6}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}}},"margin-top":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/margin-top","spec_url":"https://drafts.csswg.org/css-box/#margin-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":3}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":6}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}}},"margin-trim":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/margin-trim","spec_url":"https://drafts.csswg.org/css-box-4/#margin-trim","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":16.4}],"safari_ios":[{"added":16.4}]}}},"mask":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"partial_implementation":true,"added":1},{"prefix":"-webkit-","added":1}],"chrome_android":[{"partial_implementation":true,"added":18},{"prefix":"-webkit-","added":18}],"edge":[{"partial_implementation":true,"added":79},{"prefix":"-webkit-","added":79},{"added":12,"removed":79}],"firefox":[{"added":2}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"partial_implementation":true,"added":3.1},{"prefix":"-webkit-","added":3.1}],"safari_ios":[{"partial_implementation":true,"added":2},{"prefix":"-webkit-","added":2}]}},"_aliasOf":"mask"},"mask-border":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-border","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-border","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-mask-box-image","added":1}],"chrome_android":[{"alternative_name":"-webkit-mask-box-image","added":18}],"edge":[{"alternative_name":"-webkit-mask-box-image","added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"alternative_name":"-webkit-mask-box-image","added":3.1}],"safari_ios":[{"alternative_name":"-webkit-mask-box-image","added":3}]}},"_aliasOf":"mask-border"},"mask-border-outset":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-border-outset","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-border-outset","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-mask-box-image-outset","added":1}],"chrome_android":[{"alternative_name":"-webkit-mask-box-image-outset","added":18}],"edge":[{"alternative_name":"-webkit-mask-box-image-outset","added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"alternative_name":"-webkit-mask-box-image-outset","added":3.1}],"safari_ios":[{"alternative_name":"-webkit-mask-box-image-outset","added":3}]}},"_aliasOf":"mask-border-outset"},"mask-border-repeat":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-border-repeat","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-border-repeat","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-mask-box-image-repeat","added":1}],"chrome_android":[{"alternative_name":"-webkit-mask-box-image-repeat","added":18}],"edge":[{"alternative_name":"-webkit-mask-box-image-repeat","added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"alternative_name":"-webkit-mask-box-image-repeat","added":3.1}],"safari_ios":[{"alternative_name":"-webkit-mask-box-image-repeat","added":3}]}},"_aliasOf":"mask-border-repeat"},"mask-border-slice":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-border-slice","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-border-slice","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-mask-box-image-slice","added":1}],"chrome_android":[{"alternative_name":"-webkit-mask-box-image-slice","added":18}],"edge":[{"alternative_name":"-webkit-mask-box-image-slice","added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"alternative_name":"-webkit-mask-box-image-slice","added":3.1}],"safari_ios":[{"alternative_name":"-webkit-mask-box-image-slice","added":3}]}},"_aliasOf":"mask-border-slice"},"mask-border-source":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-border-source","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-border-source","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-mask-box-image-source","added":1}],"chrome_android":[{"alternative_name":"-webkit-mask-box-image-source","added":18}],"edge":[{"alternative_name":"-webkit-mask-box-image-source","added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"alternative_name":"-webkit-mask-box-image-source","added":3.1}],"safari_ios":[{"alternative_name":"-webkit-mask-box-image-source","added":3}]}},"_aliasOf":"mask-border-source"},"mask-border-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-border-width","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-border-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-mask-box-image-width","added":1}],"chrome_android":[{"alternative_name":"-webkit-mask-box-image-width","added":18}],"edge":[{"alternative_name":"-webkit-mask-box-image-width","added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"alternative_name":"-webkit-mask-box-image-width","added":3.1}],"safari_ios":[{"alternative_name":"-webkit-mask-box-image-width","added":3}]}},"_aliasOf":"mask-border-width"},"mask-clip":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-clip","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-clip","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":79}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":15.4},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":15.4},{"prefix":"-webkit-","added":3.2}]}},"border":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}}},"content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}}},"padding":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}}},"text":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}}},"_aliasOf":"mask-clip"},"mask-composite":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-composite","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-composite","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":18,"removed":79}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"mask-image":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-image","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-image","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":79},{"added":16,"removed":79}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":15.4},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":15.4},{"prefix":"-webkit-","added":3.2}]}},"multiple_mask_images":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":18}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}}},"svg_masks":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":8}],"chrome_android":[{"added":18}],"edge":[{"added":18}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}}},"_aliasOf":"mask-image"},"mask-mode":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-mode","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-mode","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"mask-origin":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-origin","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-origin","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":79}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":15.4},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":15.4},{"prefix":"-webkit-","added":3.2}]}},"fill-box":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"non_standard_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":15.4},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":15.4},{"prefix":"-webkit-","added":3.2}]}},"_aliasOf":"non_standard_values"},"stroke-box":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"view-box":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"mask-origin","-webkit-non_standard_values":{"_aliasOf":"non_standard_values"}},"mask-position":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-position","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-position","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":79},{"added":18,"removed":79}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":15.4},{"prefix":"-webkit-","added":3.1}],"safari_ios":[{"added":15.4},{"prefix":"-webkit-","added":2}]}},"_aliasOf":"mask-position"},"mask-repeat":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-repeat","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-repeat","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":79},{"added":18,"removed":79}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":15.4},{"prefix":"-webkit-","added":3.1}],"safari_ios":[{"added":15.4},{"prefix":"-webkit-","added":2}]}},"_aliasOf":"mask-repeat"},"mask-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-size","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-size","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"prefix":"-webkit-","added":4}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":79},{"added":18,"removed":79}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":15.4},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":15.4},{"prefix":"-webkit-","added":2}]}},"_aliasOf":"mask-size"},"mask-type":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mask-type","spec_url":"https://drafts.fxtf.org/css-masking/#the-mask-type","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":24}],"chrome_android":[{"added":25}],"edge":[{"added":79}],"firefox":[{"added":35}],"firefox_android":[{"added":35}],"ie":[{"added":false}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"masonry-auto-flow":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/masonry-auto-flow","spec_url":"https://drafts.csswg.org/css-grid-3/#masonry-auto-flow","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"math-depth":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/math-depth","spec_url":"https://w3c.github.io/mathml-core/#the-math-script-level-property","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference"}],"impl_url":"https://crrev.com/c/2423843","added":87}],"chrome_android":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference"}],"impl_url":"https://crrev.com/c/2423843","added":87}],"edge":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference"}],"impl_url":"https://crrev.com/c/2423843","added":87}],"firefox":[{"flags":[{"name":"layout.css.math-depth.enabled","type":"preference","value_to_set":"true"}],"impl_url":"https://bugzil.la/1667090","added":83}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"math-shift":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/math-shift","spec_url":"https://w3c.github.io/mathml-core/#the-math-shift","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference"}],"impl_url":"https://crrev.com/c/2421662","added":87}],"chrome_android":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference"}],"impl_url":"https://crrev.com/c/2421662","added":87}],"edge":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference"}],"impl_url":"https://crrev.com/c/2421662","added":87}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"math-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/math-style","spec_url":"https://w3c.github.io/mathml-core/#the-math-style-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference"}],"added":83}],"chrome_android":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference"}],"added":83}],"edge":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference"}],"added":83}],"firefox":[{"flags":[{"name":"layout.css.math-style.enabled","type":"preference","value_to_set":"true"}],"added":83}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"max-block-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/max-block-size","spec_url":"https://drafts.csswg.org/css-logical/#propdef-max-block-size","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"fit-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":94}],"firefox_android":[{"added":94}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"fit-content"},"fit-content_function":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.fit-content-function.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"max-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"min-content"},"-webkit-fill-available":{"_aliasOf":"fit-content"},"-moz-max-content":{"_aliasOf":"max-content"},"-moz-min-content":{"_aliasOf":"min-content"}},"max-height":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/max-height","spec_url":"https://drafts.csswg.org/css-sizing-4/#width-height-keywords","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":18}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":7}],"safari":[{"added":1.3}],"safari_ios":[{"added":1}]}},"fit-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46},{"prefix":"-webkit-","added":25}],"chrome_android":[{"added":46},{"prefix":"-webkit-","added":25}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":94},{"partial_implementation":true,"prefix":"-moz-","added":3}],"firefox_android":[{"added":94},{"partial_implementation":true,"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":7},{"alternative_name":"intrinsic","added":2}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":7},{"alternative_name":"intrinsic","added":1}]}},"_aliasOf":"fit-content"},"fit-content_function":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.fit-content-function.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"max-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46}],"chrome_android":[{"added":46}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":9}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":9}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46}],"chrome_android":[{"added":46}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":9}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":9}]}},"_aliasOf":"min-content"},"stretch":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-fill-available","added":28}],"chrome_android":[{"alternative_name":"-webkit-fill-available","added":28}],"edge":[{"alternative_name":"-webkit-fill-available","added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"stretch"},"-webkit-fit-content":{"_aliasOf":"fit-content"},"-moz-fit-content":{"_aliasOf":"fit-content"},"intrinsic":{"_aliasOf":"fit-content"},"-moz-max-content":{"_aliasOf":"max-content"},"-webkit-max-content":{"_aliasOf":"max-content"},"-moz-min-content":{"_aliasOf":"min-content"},"-webkit-min-content":{"_aliasOf":"min-content"},"-webkit-fill-available":{"_aliasOf":"stretch"}},"max-inline-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/max-inline-size","spec_url":"https://drafts.csswg.org/css-logical/#propdef-max-inline-size","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1},{"prefix":"-webkit-","added":10.1}],"safari_ios":[{"added":12.2},{"prefix":"-webkit-","added":10.3}]}},"fit-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":94},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":94},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"fit-content"},"fit-content_function":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.fit-content-function.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"max-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"min-content"},"_aliasOf":"max-inline-size","-moz-fit-content":{"_aliasOf":"fit-content"},"-webkit-fill-available":{"_aliasOf":"fit-content"},"-moz-max-content":{"_aliasOf":"max-content"},"-moz-min-content":{"_aliasOf":"min-content"}},"max-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/max-width","spec_url":"https://drafts.csswg.org/css-sizing-4/#width-height-keywords","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":7}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"fit-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46},{"prefix":"-webkit-","added":25}],"chrome_android":[{"added":46}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":94},{"partial_implementation":true,"prefix":"-moz-","added":3}],"firefox_android":[{"added":94},{"partial_implementation":true,"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":7},{"alternative_name":"intrinsic","added":2}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":7},{"alternative_name":"intrinsic","added":1}]}},"_aliasOf":"fit-content"},"fit-content_function":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.fit-content-function.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"max-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46},{"prefix":"-webkit-","added":22}],"chrome_android":[{"added":46}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":7},{"alternative_name":"intrinsic","added":2}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":7},{"alternative_name":"intrinsic","added":1}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46},{"prefix":"-webkit-","added":25}],"chrome_android":[{"added":46}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":7},{"alternative_name":"intrinsic","added":2}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":7},{"alternative_name":"intrinsic","added":1}]}},"_aliasOf":"min-content"},"stretch":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-fill-available","added":22}],"chrome_android":[{"alternative_name":"-webkit-fill-available","added":25}],"edge":[{"alternative_name":"-webkit-fill-available","added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"stretch"},"-webkit-fit-content":{"_aliasOf":"fit-content"},"-moz-fit-content":{"_aliasOf":"fit-content"},"intrinsic":{"_aliasOf":"min-content"},"-webkit-max-content":{"_aliasOf":"max-content"},"-moz-max-content":{"_aliasOf":"max-content"},"-webkit-min-content":{"_aliasOf":"min-content"},"-moz-min-content":{"_aliasOf":"min-content"},"-webkit-fill-available":{"_aliasOf":"stretch"}},"min-block-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/min-block-size","spec_url":"https://drafts.csswg.org/css-logical/#propdef-min-block-size","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"fit-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":94}],"firefox_android":[{"added":94}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"fit-content"},"fit-content_function":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.fit-content-function.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"max-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"min-content"},"-webkit-fill-available":{"_aliasOf":"fit-content"},"-moz-max-content":{"_aliasOf":"max-content"},"-moz-min-content":{"_aliasOf":"min-content"}},"min-height":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/min-height","spec_url":"https://drafts.csswg.org/css-sizing/#width-height-keywords","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3}],"firefox_android":[{"added":4}],"ie":[{"added":7}],"safari":[{"added":1.3}],"safari_ios":[{"added":1}]}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":21}],"chrome_android":[{"added":25}],"edge":[{"added":79}],"firefox":[{"added":16,"removed":22}],"firefox_android":[{"added":16,"removed":22}],"ie":[{"added":false}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"fit-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46},{"prefix":"-webkit-","added":25}],"chrome_android":[{"added":46}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":94},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":94},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":7},{"alternative_name":"intrinsic","added":2}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":7},{"alternative_name":"intrinsic","added":1}]}},"_aliasOf":"fit-content"},"fit-content_function":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.fit-content-function.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"max-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46}],"chrome_android":[{"added":46}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":9}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":9}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46}],"chrome_android":[{"added":46}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":9}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":9}]}},"_aliasOf":"min-content"},"stretch":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-fill-available","added":28}],"chrome_android":[{"alternative_name":"-webkit-fill-available","added":28}],"edge":[{"alternative_name":"-webkit-fill-available","added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"alternative_name":"-webkit-fill-available","added":9}],"safari_ios":[{"alternative_name":"-webkit-fill-available","added":9}]}},"_aliasOf":"stretch"},"-webkit-fit-content":{"_aliasOf":"fit-content"},"-moz-fit-content":{"_aliasOf":"fit-content"},"intrinsic":{"_aliasOf":"fit-content"},"-moz-max-content":{"_aliasOf":"max-content"},"-webkit-max-content":{"_aliasOf":"max-content"},"-moz-min-content":{"_aliasOf":"min-content"},"-webkit-min-content":{"_aliasOf":"min-content"},"-webkit-fill-available":{"_aliasOf":"stretch"}},"min-inline-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/min-inline-size","spec_url":"https://drafts.csswg.org/css-logical/#propdef-min-inline-size","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"fit-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":94},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":94},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"fit-content"},"fit-content_function":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.fit-content-function.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"max-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":41}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"_aliasOf":"min-content"},"-moz-fit-content":{"_aliasOf":"fit-content"},"-webkit-fill-available":{"_aliasOf":"fit-content"},"-moz-max-content":{"_aliasOf":"max-content"},"-moz-min-content":{"_aliasOf":"min-content"}},"min-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/min-width","spec_url":"https://drafts.csswg.org/css-sizing/#min-size-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":7}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":21}],"chrome_android":[{"added":25}],"edge":[{"added":12}],"firefox":[{"added":34},{"added":16,"removed":22}],"firefox_android":[{"added":34},{"added":16,"removed":22}],"ie":[{"added":false}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"fit-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46},{"prefix":"-webkit-","added":25}],"chrome_android":[{"added":46}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":94},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":94},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"fit-content"},"fit-content_function":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.fit-content-function.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"max-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46},{"prefix":"-webkit-","added":25}],"chrome_android":[{"added":46}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"alternative_name":"intrinsic","added":2}],"safari_ios":[{"added":11},{"alternative_name":"intrinsic","added":1}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46},{"prefix":"-webkit-","added":25},{"alternative_name":"min-intrinsic","added":25,"removed":48}],"chrome_android":[{"added":46},{"prefix":"-webkit-","added":25},{"alternative_name":"min-intrinsic","added":25,"removed":48}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"alternative_name":"min-intrinsic","added":2}],"safari_ios":[{"added":11},{"alternative_name":"min-intrinsic","added":1}]}},"_aliasOf":"min-content"},"stretch":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-fill-available","added":22}],"chrome_android":[{"alternative_name":"-webkit-fill-available","added":25}],"edge":[{"alternative_name":"-webkit-fill-available","added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"stretch"},"-webkit-fit-content":{"_aliasOf":"fit-content"},"-moz-fit-content":{"_aliasOf":"fit-content"},"-webkit-fill-available":{"_aliasOf":"stretch"},"-webkit-max-content":{"_aliasOf":"max-content"},"-moz-max-content":{"_aliasOf":"max-content"},"intrinsic":{"_aliasOf":"max-content"},"-webkit-min-content":{"_aliasOf":"min-content"},"min-intrinsic":{"_aliasOf":"min-content"},"-moz-min-content":{"_aliasOf":"min-content"}},"mix-blend-mode":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/mix-blend-mode","spec_url":"https://drafts.fxtf.org/compositing/#mix-blend-mode","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":41}],"chrome_android":[{"added":41}],"edge":[{"added":79}],"firefox":[{"added":32}],"firefox_android":[{"added":32}],"ie":[{"added":false}],"safari":[{"added":8}],"safari_ios":[{"added":8}]}},"plus-lighter":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":100}],"chrome_android":[{"added":100}],"edge":[{"added":100}],"firefox":[{"added":99}],"firefox_android":[{"added":99}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"svg":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":41}],"chrome_android":[{"added":false}],"edge":[{"added":79}],"firefox":[{"added":32}],"firefox_android":[{"added":32}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"object-fit":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/object-fit","spec_url":"https://drafts.csswg.org/css-images/#the-object-fit","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":32}],"chrome_android":[{"added":32}],"edge":[{"added":79},{"partial_implementation":true,"added":16,"removed":79}],"firefox":[{"added":36}],"firefox_android":[{"added":36}],"ie":[{"added":false}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}},"_aliasOf":"object-fit"},"object-position":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/object-position","spec_url":"https://drafts.csswg.org/css-images/#the-object-position","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":32}],"chrome_android":[{"added":32}],"edge":[{"added":79},{"partial_implementation":true,"added":16,"removed":79}],"firefox":[{"added":36}],"firefox_android":[{"added":36}],"ie":[{"added":false}],"safari":[{"added":10}],"safari_ios":[{"added":10}]}},"_aliasOf":"object-position"},"object-view-box":{"__compat":{"spec_url":"https://drafts.csswg.org/css-images-4/#the-object-view-box","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":104}],"chrome_android":[{"added":104}],"edge":[{"added":104}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"offset":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/offset","spec_url":"https://drafts.fxtf.org/motion/#offset-shorthand","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":55},{"alternative_name":"motion","added":46}],"chrome_android":[{"added":55},{"alternative_name":"motion","added":46}],"edge":[{"added":79},{"alternative_name":"motion","added":79}],"firefox":[{"added":72}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}},"_aliasOf":"offset"},"offset-anchor":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/offset-anchor","spec_url":"https://drafts.fxtf.org/motion/#offset-anchor-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":72}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}},"offset-distance":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/offset-distance","spec_url":"https://drafts.fxtf.org/motion/#offset-distance-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":55},{"alternative_name":"motion-distance","added":46}],"chrome_android":[{"added":55},{"alternative_name":"motion-distance","added":46}],"edge":[{"added":79},{"alternative_name":"motion-distance","added":79}],"firefox":[{"added":72}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}},"_aliasOf":"offset-distance"},"offset-path":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/offset-path","spec_url":"https://drafts.fxtf.org/motion/#offset-path-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":55},{"alternative_name":"motion-path","added":46}],"chrome_android":[{"added":55},{"alternative_name":"motion-path","added":46}],"edge":[{"added":79},{"alternative_name":"motion-path","added":79}],"firefox":[{"added":72}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}},"path-support":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":64}],"chrome_android":[{"added":64}],"edge":[{"added":79}],"firefox":[{"added":72}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}},"ray-support":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":64}],"chrome_android":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":64}],"edge":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":79}],"firefox":[{"flags":[{"name":"layout.css.motion-path-ray.enabled","type":"preference","value_to_set":"true"}],"added":112},{"flags":[{"name":"layout.css.motion-path-ray.enabled","type":"preference","value_to_set":"true"}],"added":72}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}},"_aliasOf":"offset-path"},"offset-position":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/offset-position","spec_url":"https://drafts.fxtf.org/motion/#offset-position-property","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}},"offset-rotate":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/offset-rotate","spec_url":"https://drafts.fxtf.org/motion/#offset-rotate-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":56},{"alternative_name":"offset-rotation","added":55},{"alternative_name":"motion-rotation","added":46}],"chrome_android":[{"added":56},{"alternative_name":"offset-rotation","added":55},{"alternative_name":"motion-rotation","added":46}],"edge":[{"added":79},{"alternative_name":"offset-rotation","added":79},{"alternative_name":"motion-rotation","added":79}],"firefox":[{"added":72}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}},"_aliasOf":"offset-rotate"},"opacity":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/opacity","spec_url":"https://drafts.csswg.org/css-color/#transparency","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1},{"prefix":"-moz-","added":1,"removed":3.5}],"firefox_android":[{"added":4}],"ie":[{"added":9}],"safari":[{"added":2},{"prefix":"-khtml-","added":1.1,"removed":2}],"safari_ios":[{"added":1}]}},"percentages":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":78}],"chrome_android":[{"added":78}],"edge":[{"added":79}],"firefox":[{"added":70}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}},"_aliasOf":"opacity"},"order":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/order","spec_url":"https://drafts.csswg.org/css-display/#order-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":29},{"prefix":"-webkit-","added":21}],"chrome_android":[{"added":29},{"prefix":"-webkit-","added":25}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":20},{"prefix":"-webkit-","added":49}],"firefox_android":[{"added":20},{"prefix":"-webkit-","added":49}],"ie":[{"added":11},{"prefix":"-ms-","added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"order"},"orphans":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/orphans","spec_url":"https://drafts.csswg.org/css-break/#widows-orphans","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":25}],"chrome_android":[{"added":25}],"edge":[{"added":12}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":8}],"safari":[{"added":1.3}],"safari_ios":[{"added":1}]}}},"outline":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/outline","spec_url":"https://drafts.csswg.org/css-ui/#outline","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":94},{"partial_implementation":true,"added":1,"removed":94}],"chrome_android":[{"added":94},{"partial_implementation":true,"added":18,"removed":94}],"edge":[{"added":94},{"partial_implementation":true,"added":12,"removed":94}],"firefox":[{"added":88},{"partial_implementation":true,"added":1.5,"removed":88},{"prefix":"-moz-","added":1,"removed":3.6}],"firefox_android":[{"added":88},{"partial_implementation":true,"added":4,"removed":88}],"ie":[{"added":8}],"safari":[{"partial_implementation":true,"added":1.2}],"safari_ios":[{"partial_implementation":true,"added":1}]}},"invert":{"__compat":{"spec_url":"https://drafts.csswg.org/css-ui/#valdef-outline-color-invert","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79}],"firefox":[{"added":1,"removed":3}],"firefox_android":[{"added":false}],"ie":[{"added":8}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"outline"},"outline-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/outline-color","spec_url":"https://drafts.csswg.org/css-ui/#outline-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5},{"prefix":"-moz-","added":1,"removed":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}},"invert":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79}],"firefox":[{"added":1,"removed":3}],"firefox_android":[{"added":false}],"ie":[{"added":8}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"outline-color"},"outline-offset":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/outline-offset","spec_url":"https://drafts.csswg.org/css-ui/#outline-offset","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":15}],"firefox":[{"added":1.5}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"outline-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/outline-style","spec_url":"https://drafts.csswg.org/css-ui/#outline-style","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5},{"prefix":"-moz-","added":1,"removed":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":1.5}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"_aliasOf":"outline-style"},"outline-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/outline-width","spec_url":"https://drafts.csswg.org/css-ui/#outline-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5},{"prefix":"-moz-","added":1,"removed":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}},"_aliasOf":"outline-width"},"overflow":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overflow","spec_url":"https://drafts.csswg.org/css-overflow/#propdef-overflow","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"clip":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":90}],"chrome_android":[{"added":90}],"edge":[{"added":90}],"firefox":[{"added":81},{"alternative_name":"-moz-hidden-unscrollable","added":1.5,"removed":81}],"firefox_android":[{"added":81},{"alternative_name":"-moz-hidden-unscrollable","added":4,"removed":81}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}},"_aliasOf":"clip"},"multiple_keywords":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":68}],"chrome_android":[{"added":68}],"edge":[{"added":79}],"firefox":[{"added":61}],"firefox_android":[{"added":61}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}},"overlay":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":15}],"chrome_android":[{"added":100}],"edge":[{"added":79}],"firefox":[{"added":112}],"firefox_android":[{"added":112}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"-moz-hidden-unscrollable":{"_aliasOf":"clip"}},"overflow-anchor":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overflow-anchor","spec_url":"https://drafts.csswg.org/css-scroll-anchoring/#exclusion-api","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":56}],"chrome_android":[{"added":56}],"edge":[{"added":79}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"overflow-block":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overflow-block","spec_url":"https://drafts.csswg.org/css-overflow/#overflow-control","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":69}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"overflow-clip-box":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":29}],"firefox_android":[{"added":29}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"shorthand":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":59}],"firefox_android":[{"added":59}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"overflow-clip-box-block":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":59}],"firefox_android":[{"added":59}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"overflow-clip-box-inline":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":59}],"firefox_android":[{"added":59}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"overflow-clip-margin":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overflow-clip-margin","spec_url":"https://drafts.csswg.org/css-overflow/#overflow-clip-margin","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"partial_implementation":true,"added":90}],"chrome_android":[{"partial_implementation":true,"added":90}],"edge":[{"partial_implementation":true,"added":90}],"firefox":[{"partial_implementation":true,"added":102}],"firefox_android":[{"partial_implementation":true,"added":102}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"overflow-inline":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overflow-inline","spec_url":"https://drafts.csswg.org/css-overflow/#overflow-control","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":69}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"overflow-wrap":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overflow-wrap","spec_url":"https://drafts.csswg.org/css-text/#overflow-wrap-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":23},{"alternative_name":"word-wrap","added":1}],"chrome_android":[{"added":25},{"alternative_name":"word-wrap","added":18}],"edge":[{"added":18},{"alternative_name":"word-wrap","added":12}],"firefox":[{"added":49},{"alternative_name":"word-wrap","added":3.5}],"firefox_android":[{"added":49},{"alternative_name":"word-wrap","added":4}],"ie":[{"alternative_name":"word-wrap","added":5.5}],"safari":[{"added":7},{"alternative_name":"word-wrap","added":1}],"safari_ios":[{"added":7},{"alternative_name":"word-wrap","added":1}]}},"anywhere":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":80}],"chrome_android":[{"added":80}],"edge":[{"added":80}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"break-word":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.5}],"firefox_android":[{"added":4}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"_aliasOf":"overflow-wrap"},"overflow-x":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overflow-x","spec_url":"https://drafts.csswg.org/css-overflow/#overflow-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.5}],"firefox_android":[{"added":4}],"ie":[{"added":5},{"prefix":"-ms-","added":8}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}},"clip":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":90}],"chrome_android":[{"added":90}],"edge":[{"added":90}],"firefox":[{"added":81},{"alternative_name":"-moz-hidden-unscrollable","added":3.5,"removed":81}],"firefox_android":[{"added":81},{"alternative_name":"-moz-hidden-unscrollable","added":4,"removed":81}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}},"_aliasOf":"clip"},"overlay":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":15}],"chrome_android":[{"added":100}],"edge":[{"added":79}],"firefox":[{"added":112}],"firefox_android":[{"added":112}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"overflow-x","-moz-hidden-unscrollable":{"_aliasOf":"clip"}},"overflow-y":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overflow-y","spec_url":"https://drafts.csswg.org/css-overflow/#overflow-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.5}],"firefox_android":[{"added":4}],"ie":[{"added":5},{"prefix":"-ms-","added":8}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}},"clip":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":90}],"chrome_android":[{"added":90}],"edge":[{"added":90}],"firefox":[{"added":81},{"alternative_name":"-moz-hidden-unscrollable","added":3.5,"removed":81}],"firefox_android":[{"added":81},{"alternative_name":"-moz-hidden-unscrollable","added":4,"removed":81}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}},"_aliasOf":"clip"},"overlay":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":15}],"chrome_android":[{"added":100}],"edge":[{"added":79}],"firefox":[{"added":112}],"firefox_android":[{"added":112}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"overflow-y","-moz-hidden-unscrollable":{"_aliasOf":"clip"}},"overscroll-behavior":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overscroll-behavior","spec_url":"https://drafts.csswg.org/css-overscroll/#overscroll-behavior-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":63}],"chrome_android":[{"added":63}],"edge":[{"partial_implementation":true,"added":18}],"firefox":[{"added":59}],"firefox_android":[{"added":59}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"overscroll-behavior-block":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overscroll-behavior-block","spec_url":"https://drafts.csswg.org/css-overscroll/#overscroll-behavior-longhands-logical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":77}],"chrome_android":[{"added":77}],"edge":[{"added":79}],"firefox":[{"added":73}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"overscroll-behavior-inline":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overscroll-behavior-inline","spec_url":"https://drafts.csswg.org/css-overscroll/#overscroll-behavior-longhands-logical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":77}],"chrome_android":[{"added":77}],"edge":[{"added":79}],"firefox":[{"added":73}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"overscroll-behavior-x":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overscroll-behavior-x","spec_url":"https://drafts.csswg.org/css-overscroll/#overscroll-behavior-longhands-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":63}],"chrome_android":[{"added":63}],"edge":[{"partial_implementation":true,"added":18}],"firefox":[{"added":59}],"firefox_android":[{"added":59}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"overscroll-behavior-y":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/overscroll-behavior-y","spec_url":"https://drafts.csswg.org/css-overscroll/#overscroll-behavior-longhands-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":63}],"chrome_android":[{"added":63}],"edge":[{"partial_implementation":true,"added":18}],"firefox":[{"added":59}],"firefox_android":[{"added":59}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"padding":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/padding","spec_url":"https://drafts.csswg.org/css-box/#padding-shorthand","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"padding-block":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/padding-block","spec_url":"https://drafts.csswg.org/css-logical/#propdef-padding-block","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"padding-block-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/padding-block-end","spec_url":"https://drafts.csswg.org/css-logical/#padding-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"padding-block-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/padding-block-start","spec_url":"https://drafts.csswg.org/css-logical/#padding-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"padding-bottom":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/padding-bottom","spec_url":"https://drafts.csswg.org/css-box/#padding-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"padding-inline":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/padding-inline","spec_url":"https://drafts.csswg.org/css-logical/#propdef-padding-inline","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":66}],"firefox_android":[{"added":66}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"padding-inline-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/padding-inline-end","spec_url":"https://drafts.csswg.org/css-logical/#padding-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87},{"alternative_name":"-webkit-padding-end","added":2}],"chrome_android":[{"added":87},{"alternative_name":"-webkit-padding-end","added":18}],"edge":[{"added":87},{"alternative_name":"-webkit-padding-end","added":79}],"firefox":[{"added":41},{"alternative_name":"-moz-padding-end","added":3}],"firefox_android":[{"added":41},{"alternative_name":"-moz-padding-end","added":4}],"ie":[{"added":false}],"safari":[{"added":12.1},{"alternative_name":"-webkit-padding-end","added":3}],"safari_ios":[{"added":12.2},{"alternative_name":"-webkit-padding-end","added":3}]}},"_aliasOf":"padding-inline-end"},"padding-inline-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/padding-inline-start","spec_url":"https://drafts.csswg.org/css-logical/#padding-properties","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87},{"alternative_name":"-webkit-padding-start","added":2}],"chrome_android":[{"added":87},{"alternative_name":"-webkit-padding-start","added":18}],"edge":[{"added":87},{"alternative_name":"-webkit-padding-start","added":79}],"firefox":[{"added":41},{"alternative_name":"-moz-padding-start","added":3}],"firefox_android":[{"added":41},{"alternative_name":"-moz-padding-start","added":4}],"ie":[{"added":false}],"safari":[{"added":12.1},{"alternative_name":"-webkit-padding-start","added":3}],"safari_ios":[{"added":12.2},{"alternative_name":"-webkit-padding-start","added":3}]}},"_aliasOf":"padding-inline-start"},"padding-left":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/padding-left","spec_url":"https://drafts.csswg.org/css-box/#padding-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"padding-right":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/padding-right","spec_url":"https://drafts.csswg.org/css-box/#padding-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"padding-top":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/padding-top","spec_url":"https://drafts.csswg.org/css-box/#padding-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"page":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/page","spec_url":"https://drafts.csswg.org/css-page/#using-named-pages","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":85}],"chrome_android":[{"added":85}],"edge":[{"added":85}],"firefox":[{"added":110}],"firefox_android":[{"added":110}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}},"page-break-after":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/page-break-after","spec_url":["https://drafts.csswg.org/css-logical/#page","https://drafts.csswg.org/css-page/#page-break-after"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"page-break-before":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/page-break-before","spec_url":["https://drafts.csswg.org/css-logical/#page","https://drafts.csswg.org/css-page/#page-break-before"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1.2}],"safari_ios":[{"added":1}]}}},"page-break-inside":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/page-break-inside","spec_url":"https://drafts.csswg.org/css-page/#page-break-inside","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":19}],"firefox_android":[{"added":19}],"ie":[{"added":8}],"safari":[{"added":1.3}],"safari_ios":[{"added":1}]}}},"paint-order":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/paint-order","spec_url":"https://svgwg.org/svg2-draft/painting.html#PaintOrder","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":35}],"chrome_android":[{"added":35}],"edge":[{"added":17}],"firefox":[{"added":60}],"firefox_android":[{"added":60}],"ie":[{"added":false}],"safari":[{"added":8}],"safari_ios":[{"added":8}]}}},"perspective":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/perspective","spec_url":"https://drafts.csswg.org/css-transforms-2/#perspective-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":36},{"prefix":"-webkit-","added":12}],"chrome_android":[{"added":36},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":10}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":10}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":2}]}},"_aliasOf":"perspective"},"perspective-origin":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/perspective-origin","spec_url":"https://drafts.csswg.org/css-transforms-2/#perspective-origin-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":36},{"prefix":"-webkit-","added":12}],"chrome_android":[{"added":36},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":10}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":10}],"ie":[{"added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":2}]}},"_aliasOf":"perspective-origin"},"place-content":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/place-content","spec_url":"https://drafts.csswg.org/css-align/#place-content","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":59}],"chrome_android":[{"added":59}],"edge":[{"added":79}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}},"flex_context":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":59}],"chrome_android":[{"added":59}],"edge":[{"added":79}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}}},"grid_context":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":59}],"chrome_android":[{"added":59}],"edge":[{"added":79}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}}},"place-items":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/place-items","spec_url":"https://drafts.csswg.org/css-align/#place-items-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":59}],"chrome_android":[{"added":59}],"edge":[{"added":79}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}},"flex_context":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":59}],"chrome_android":[{"added":59}],"edge":[{"added":79}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"grid_context":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":59}],"chrome_android":[{"added":59}],"edge":[{"added":79}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}}},"place-self":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/place-self","spec_url":"https://drafts.csswg.org/css-align/#place-self-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":59}],"chrome_android":[{"added":59}],"edge":[{"added":79}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}},"flex_context":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":59}],"chrome_android":[{"added":59}],"edge":[{"added":79}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"grid_context":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":59}],"chrome_android":[{"added":59}],"edge":[{"added":79}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}}},"pointer-events":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/pointer-events","spec_url":["https://drafts.csswg.org/css-ui/#pointer-events-control","https://svgwg.org/svg2-draft/interact.html#PointerEventsProperty"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":4}],"ie":[{"added":11}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}},"html_elements":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":2}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":11}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}}}},"position":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/position","spec_url":"https://drafts.csswg.org/css-position/#position-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"absolutely_positioned_flex_children":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":52}],"chrome_android":[{"added":52}],"edge":[{"added":12}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":10}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"fixed":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":7}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"position_sticky_table_elements":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":56}],"chrome_android":[{"added":56}],"edge":[{"added":16}],"firefox":[{"added":59}],"firefox_android":[{"added":59}],"ie":[{"added":false}],"safari":[{"added":8}],"safari_ios":[{"added":8}]}}},"sticky":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":56}],"chrome_android":[{"added":56}],"edge":[{"added":16}],"firefox":[{"added":32}],"firefox_android":[{"added":32}],"ie":[{"added":false}],"safari":[{"added":13},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":13},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"sticky"},"-webkit-sticky":{"_aliasOf":"sticky"}},"print-color-adjust":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/print-color-adjust","spec_url":"https://drafts.csswg.org/css-color-adjust/#propdef-print-color-adjust","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"prefix":"-webkit-","added":17}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":79}],"firefox":[{"added":97},{"alternative_name":"color-adjust","added":48}],"firefox_android":[{"added":97},{"alternative_name":"color-adjust","added":48}],"ie":[{"added":false}],"safari":[{"added":15.4},{"prefix":"-webkit-","added":6}],"safari_ios":[{"added":15.4},{"prefix":"-webkit-","added":6}]}},"_aliasOf":"print-color-adjust"},"quotes":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/quotes","spec_url":"https://drafts.csswg.org/css-content/#quotes","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":11}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1.5}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}},"quotes_auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":70}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}}},"resize":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/resize","spec_url":"https://drafts.csswg.org/css-ui/#resize","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":4}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}},"block_level_support":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":5}],"firefox_android":[{"added":5}],"ie":[{"added":false}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}}},"flow_relative_support":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"flags":[{"name":"enable-experimental-web-platform-features","type":"preference","value_to_set":"enabled"}],"added":70}],"chrome_android":[{"flags":[{"name":"enable-experimental-web-platform-features","type":"preference","value_to_set":"enabled"}],"added":70}],"edge":[{"flags":[{"name":"enable-experimental-web-platform-features","type":"preference","value_to_set":"enabled"}],"added":79}],"firefox":[{"added":63}],"firefox_android":[{"added":63}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}}},"right":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/right","spec_url":"https://drafts.csswg.org/css-position/#insets","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"rotate":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/rotate","spec_url":"https://drafts.csswg.org/css-transforms-2/#individual-transforms","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":104}],"chrome_android":[{"added":104}],"edge":[{"added":104}],"firefox":[{"added":72}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}},"x_y_z_angle":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":104}],"chrome_android":[{"added":104}],"edge":[{"added":104}],"firefox":[{"added":72}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}}},"row-gap":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/row-gap","spec_url":"https://drafts.csswg.org/css-align/#column-row-gap","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":47}],"chrome_android":[{"added":47}],"edge":[{"added":16}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"flex_context":{"__compat":{"spec_url":"https://drafts.csswg.org/css-align/#column-row-gap","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":84}],"chrome_android":[{"added":84}],"edge":[{"added":84}],"firefox":[{"added":63}],"firefox_android":[{"added":63}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"grid_context":{"__compat":{"spec_url":"https://drafts.csswg.org/css-align/#column-row-gap","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":66},{"alternative_name":"grid-row-gap","added":57}],"chrome_android":[{"added":66},{"alternative_name":"grid-row-gap","added":57}],"edge":[{"added":16},{"alternative_name":"grid-row-gap","added":16}],"firefox":[{"added":61},{"alternative_name":"grid-row-gap","added":52}],"firefox_android":[{"added":61},{"alternative_name":"grid-row-gap","added":52}],"ie":[{"added":false}],"safari":[{"added":12},{"alternative_name":"grid-row-gap","added":10.1}],"safari_ios":[{"added":12},{"alternative_name":"grid-row-gap","added":10.3}]}},"_aliasOf":"grid_context"},"grid-row-gap":{"_aliasOf":"grid_context"}},"ruby-align":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/ruby-align","spec_url":"https://drafts.csswg.org/css-ruby/#ruby-align-property","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":38}],"firefox_android":[{"added":38}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"ruby-position":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/ruby-position","spec_url":"https://drafts.csswg.org/css-ruby/#rubypos","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":84},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":84},{"prefix":"-webkit-","added":18}],"edge":[{"added":84},{"prefix":"-webkit-","added":79},{"added":12,"removed":79}],"firefox":[{"added":38}],"firefox_android":[{"added":38}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":7}],"safari_ios":[{"prefix":"-webkit-","added":7}]}},"alternate":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":88}],"firefox_android":[{"added":88}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"inter-character":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"ruby-position"},"scale":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scale","spec_url":"https://drafts.csswg.org/css-transforms-2/#individual-transforms","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":104}],"chrome_android":[{"added":104}],"edge":[{"added":104}],"firefox":[{"added":72}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"scroll-behavior":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-behavior","spec_url":"https://drafts.csswg.org/css-overflow/#smooth-scrolling","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":61}],"chrome_android":[{"added":61}],"edge":[{"added":79}],"firefox":[{"added":36}],"firefox_android":[{"added":36}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"scroll-margin":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-margin","spec_url":"https://drafts.csswg.org/css-scroll-snap/#scroll-margin","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":90},{"partial_implementation":true,"added":68,"removed":90}],"firefox_android":[{"added":90},{"partial_implementation":true,"added":68,"removed":90}],"ie":[{"added":false}],"safari":[{"added":14.1},{"alternative_name":"scroll-snap-margin","partial_implementation":true,"added":11}],"safari_ios":[{"added":14.5},{"alternative_name":"scroll-snap-margin","partial_implementation":true,"added":11}]}},"_aliasOf":"scroll-margin"},"scroll-margin-block":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-margin-block","spec_url":"https://drafts.csswg.org/css-scroll-snap/#propdef-scroll-margin-block","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-margin-block-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-margin-block-end","spec_url":"https://drafts.csswg.org/css-scroll-snap/#margin-longhands-logical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-margin-block-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-margin-block-start","spec_url":"https://drafts.csswg.org/css-scroll-snap/#margin-longhands-logical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-margin-bottom":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-margin-bottom","spec_url":"https://drafts.csswg.org/css-scroll-snap/#margin-longhands-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":14.1},{"alternative_name":"scroll-snap-margin-bottom","partial_implementation":true,"added":11}],"safari_ios":[{"added":14.5},{"alternative_name":"scroll-snap-margin-bottom","partial_implementation":true,"added":11}]}},"_aliasOf":"scroll-margin-bottom"},"scroll-margin-inline":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-margin-inline","spec_url":"https://drafts.csswg.org/css-scroll-snap/#propdef-scroll-margin-inline","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-margin-inline-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-margin-inline-end","spec_url":"https://drafts.csswg.org/css-scroll-snap/#margin-longhands-logical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-margin-inline-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-margin-inline-start","spec_url":"https://drafts.csswg.org/css-scroll-snap/#margin-longhands-logical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-margin-left":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-margin-left","spec_url":"https://drafts.csswg.org/css-scroll-snap/#margin-longhands-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":14.1},{"alternative_name":"scroll-snap-margin-left","partial_implementation":true,"added":11}],"safari_ios":[{"added":14.5},{"alternative_name":"scroll-snap-margin-left","partial_implementation":true,"added":11}]}},"_aliasOf":"scroll-margin-left"},"scroll-margin-right":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-margin-right","spec_url":"https://drafts.csswg.org/css-scroll-snap/#margin-longhands-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":14.1},{"alternative_name":"scroll-snap-margin-right","partial_implementation":true,"added":11}],"safari_ios":[{"added":14.5},{"alternative_name":"scroll-snap-margin-right","partial_implementation":true,"added":11}]}},"_aliasOf":"scroll-margin-right"},"scroll-margin-top":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-margin-top","spec_url":"https://drafts.csswg.org/css-scroll-snap/#margin-longhands-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":14.1},{"alternative_name":"scroll-snap-margin-top","partial_implementation":true,"added":11}],"safari_ios":[{"added":14.5},{"alternative_name":"scroll-snap-margin-top","partial_implementation":true,"added":11}]}},"_aliasOf":"scroll-margin-top"},"scroll-padding":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-padding","spec_url":"https://drafts.csswg.org/css-scroll-snap/#scroll-padding","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":14.1},{"partial_implementation":true,"added":11,"removed":14.1}],"safari_ios":[{"added":14.5},{"partial_implementation":true,"added":11,"removed":14.5}]}}},"scroll-padding-block":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-padding-block","spec_url":"https://drafts.csswg.org/css-scroll-snap/#propdef-scroll-padding-block","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-padding-block-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-padding-block-end","spec_url":"https://drafts.csswg.org/css-scroll-snap/#padding-longhands-logical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-padding-block-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-padding-block-start","spec_url":"https://drafts.csswg.org/css-scroll-snap/#padding-longhands-logical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-padding-bottom":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-padding-bottom","spec_url":"https://drafts.csswg.org/css-scroll-snap/#padding-longhands-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":14.1},{"partial_implementation":true,"added":11,"removed":14.1}],"safari_ios":[{"added":14.5},{"partial_implementation":true,"added":11,"removed":14.5}]}}},"scroll-padding-inline":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-padding-inline","spec_url":"https://drafts.csswg.org/css-scroll-snap/#propdef-scroll-padding-inline","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-padding-inline-end":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-padding-inline-end","spec_url":"https://drafts.csswg.org/css-scroll-snap/#padding-longhands-logical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-padding-inline-start":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-padding-inline-start","spec_url":"https://drafts.csswg.org/css-scroll-snap/#padding-longhands-logical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-padding-left":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-padding-left","spec_url":"https://drafts.csswg.org/css-scroll-snap/#padding-longhands-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":14.1},{"partial_implementation":true,"added":11,"removed":14.1}],"safari_ios":[{"added":14.5},{"partial_implementation":true,"added":11,"removed":14.5}]}}},"scroll-padding-right":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-padding-right","spec_url":"https://drafts.csswg.org/css-scroll-snap/#padding-longhands-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":14.1},{"partial_implementation":true,"added":11,"removed":14.1}],"safari_ios":[{"added":14.5},{"partial_implementation":true,"added":11,"removed":14.5}]}}},"scroll-padding-top":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-padding-top","spec_url":"https://drafts.csswg.org/css-scroll-snap/#padding-longhands-physical","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":14.1},{"partial_implementation":true,"added":11,"removed":14.1}],"safari_ios":[{"added":14.5},{"partial_implementation":true,"added":11,"removed":14.5}]}}},"scroll-snap-align":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-snap-align","spec_url":"https://drafts.csswg.org/css-scroll-snap/#scroll-snap-align","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79}],"firefox":[{"added":68}],"firefox_android":[{"added":68}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"scroll-snap-coordinate":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-snap-coordinate","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":39,"removed":68}],"firefox_android":[{"added":39,"removed":68}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"scroll-snap-destination":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-snap-destination","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":39,"removed":68}],"firefox_android":[{"added":39,"removed":68}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"scroll-snap-points-x":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-snap-points-x","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":39,"removed":68}],"firefox_android":[{"added":39,"removed":68}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":9}],"safari_ios":[{"prefix":"-webkit-","added":9}]}},"_aliasOf":"scroll-snap-points-x"},"scroll-snap-points-y":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-snap-points-y","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":39,"removed":68}],"firefox_android":[{"added":39,"removed":68}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":9}],"safari_ios":[{"prefix":"-webkit-","added":9}]}},"_aliasOf":"scroll-snap-points-y"},"scroll-snap-stop":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-snap-stop","spec_url":"https://drafts.csswg.org/css-scroll-snap/#scroll-snap-stop","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":75}],"chrome_android":[{"added":75}],"edge":[{"added":79}],"firefox":[{"added":103}],"firefox_android":[{"added":103}],"ie":[{"added":false}],"safari":[{"added":15}],"safari_ios":[{"added":15}]}}},"scroll-snap-type":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-snap-type","spec_url":"https://drafts.csswg.org/css-scroll-snap/#scroll-snap-type","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":69}],"chrome_android":[{"added":69}],"edge":[{"added":79},{"prefix":"-ms-","added":12,"removed":79}],"firefox":[{"added":99},{"added":39,"removed":68},{"partial_implementation":true,"added":68,"removed":99}],"firefox_android":[{"added":68},{"added":39,"removed":68}],"ie":[{"prefix":"-ms-","added":10}],"safari":[{"added":11},{"prefix":"-webkit-","added":9}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":9}]}},"_aliasOf":"scroll-snap-type"},"scroll-snap-type-x":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-snap-type-x","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":39,"removed":68}],"firefox_android":[{"added":39,"removed":68}],"ie":[{"added":false}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}}},"scroll-snap-type-y":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-snap-type-y","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":39,"removed":68}],"firefox_android":[{"added":39,"removed":68}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"scroll-timeline":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-timeline","spec_url":"https://drafts.csswg.org/scroll-animations/#scroll-timeline-shorthand","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":111},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":108}],"chrome_android":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":111},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":108}],"edge":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":111},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":108}],"firefox":[{"flags":[{"name":"layout.css.scroll-driven-animations.enabled","type":"preference","value_to_set":"true"}],"added":111},{"flags":[{"name":"layout.css.scroll-linked-animations.enabled","type":"preference","value_to_set":"true"}],"added":103}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"scroll-timeline-axis":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-timeline-axis","spec_url":"https://drafts.csswg.org/scroll-animations/#propdef-scroll-timeline-axis","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":111},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":108}],"chrome_android":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":111},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":108}],"edge":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":111},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":108}],"firefox":[{"flags":[{"name":"layout.css.scroll-driven-animations.enabled","type":"preference","value_to_set":"true"}],"added":111},{"flags":[{"name":"layout.css.scroll-linked-animations.enabled","type":"preference","value_to_set":"true"}],"added":103}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"scroll-timeline-name":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scroll-timeline-name","spec_url":"https://drafts.csswg.org/scroll-animations/#scroll-timeline-name","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":111},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":108}],"chrome_android":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":111},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":108}],"edge":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":111},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":108}],"firefox":[{"flags":[{"name":"layout.css.scroll-driven-animations.enabled","type":"preference","value_to_set":"true"}],"added":111},{"flags":[{"name":"layout.css.scroll-linked-animations.enabled","type":"preference","value_to_set":"true"}],"added":103}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"scrollbar-3dlight-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scrollbar-3dlight-color","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":5},{"prefix":"-ms-","added":8}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"scrollbar-3dlight-color"},"scrollbar-arrow-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scrollbar-arrow-color","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":5},{"prefix":"-ms-","added":8}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"scrollbar-arrow-color"},"scrollbar-base-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scrollbar-base-color","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":5},{"prefix":"-ms-","added":8}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"scrollbar-base-color"},"scrollbar-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scrollbar-color","spec_url":"https://drafts.csswg.org/css-scrollbars/#scrollbar-color","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":64}],"firefox_android":[{"added":64}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"scrollbar-darkshadow-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scrollbar-darkshadow-color","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":5},{"prefix":"-ms-","added":8}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"scrollbar-darkshadow-color"},"scrollbar-face-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scrollbar-face-color","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":5},{"prefix":"-ms-","added":8}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"scrollbar-face-color"},"scrollbar-gutter":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scrollbar-gutter","spec_url":"https://drafts.csswg.org/css-overflow/#scrollbar-gutter-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":94}],"chrome_android":[{"added":94}],"edge":[{"added":94}],"firefox":[{"added":97}],"firefox_android":[{"added":97}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"scrollbar-highlight-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scrollbar-highlight-color","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":5},{"prefix":"-ms-","added":8}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"scrollbar-highlight-color"},"scrollbar-shadow-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scrollbar-shadow-color","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":5},{"prefix":"-ms-","added":8}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"scrollbar-shadow-color"},"scrollbar-width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/scrollbar-width","spec_url":"https://drafts.csswg.org/css-scrollbars/#scrollbar-width","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":64}],"firefox_android":[{"added":64}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"shape-image-threshold":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/shape-image-threshold","spec_url":"https://drafts.csswg.org/css-shapes/#shape-image-threshold-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":37}],"chrome_android":[{"added":37}],"edge":[{"added":79}],"firefox":[{"added":62}],"firefox_android":[{"added":62}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"percentages":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":78}],"chrome_android":[{"added":78}],"edge":[{"added":79}],"firefox":[{"added":70}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"shape-margin":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/shape-margin","spec_url":"https://drafts.csswg.org/css-shapes/#shape-margin-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":37}],"chrome_android":[{"added":37}],"edge":[{"added":79}],"firefox":[{"added":62}],"firefox_android":[{"added":62}],"ie":[{"added":false}],"safari":[{"added":10.1},{"prefix":"-webkit-","added":10.1}],"safari_ios":[{"added":10.3}]}},"_aliasOf":"shape-margin"},"shape-outside":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/shape-outside","spec_url":"https://drafts.csswg.org/css-shapes/#shape-outside-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":37}],"chrome_android":[{"added":37}],"edge":[{"added":79}],"firefox":[{"added":62}],"firefox_android":[{"added":62}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}},"circle":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/basic-shape#circle()","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":37}],"chrome_android":[{"added":37}],"edge":[{"added":79}],"firefox":[{"added":62}],"firefox_android":[{"added":62}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"gradient":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/gradient","spec_url":"https://drafts.csswg.org/css-images/#gradients","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":37}],"chrome_android":[{"added":37}],"edge":[{"added":79}],"firefox":[{"added":62}],"firefox_android":[{"added":62}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"image":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/image","spec_url":"https://drafts.csswg.org/css-images/#image-values","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":37}],"chrome_android":[{"added":37}],"edge":[{"added":79}],"firefox":[{"added":62}],"firefox_android":[{"added":62}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"inset":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/basic-shape#inset()","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":37}],"chrome_android":[{"added":37}],"edge":[{"added":79}],"firefox":[{"added":62}],"firefox_android":[{"added":62}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"path":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/path","spec_url":"https://drafts.csswg.org/css-shapes/#funcdef-basic-shape-path","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"polygon":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/basic-shape#polygon()","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":37}],"chrome_android":[{"added":37}],"edge":[{"added":79}],"firefox":[{"added":62}],"firefox_android":[{"added":62}],"ie":[{"added":false}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}}},"tab-size":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/tab-size","spec_url":"https://drafts.csswg.org/css-text/#tab-size-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":21}],"chrome_android":[{"added":25}],"edge":[{"added":79}],"firefox":[{"added":91},{"prefix":"-moz-","added":4}],"firefox_android":[{"added":91},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}},"length":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/length","spec_url":"https://drafts.csswg.org/css-values/#lengths","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":42}],"chrome_android":[{"added":42}],"edge":[{"added":79}],"firefox":[{"added":53}],"firefox_android":[{"added":53}],"ie":[{"added":false}],"safari":[{"added":13.1}],"safari_ios":[{"added":13.4}]}}},"_aliasOf":"tab-size"},"table-layout":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/table-layout","spec_url":"https://drafts.csswg.org/css2/#width-layout","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":14}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5}],"safari":[{"added":1}],"safari_ios":[{"added":3}]}}},"text-align":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-align","spec_url":["https://drafts.csswg.org/css-logical/#text-align","https://drafts.csswg.org/css-text/#text-align-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":3}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"block_alignment_values":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":79}],"firefox":[{"prefix":"-moz-","added":1}],"firefox_android":[{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":null},{"prefix":"-webkit-","added":1.3},{"prefix":"-khtml-","added":1}],"safari_ios":[{"prefix":"-webkit-","added":1},{"prefix":"-khtml-","added":1}]}},"_aliasOf":"block_alignment_values"},"flow_relative_values_start_and_end":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":3.1}],"safari_ios":[{"added":2}]}}},"match-parent":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":16}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":40}],"firefox_android":[{"added":40}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}},"-webkit-block_alignment_values":{"_aliasOf":"block_alignment_values"},"-moz-block_alignment_values":{"_aliasOf":"block_alignment_values"},"-khtml-block_alignment_values":{"_aliasOf":"block_alignment_values"}},"text-align-last":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-align-last","spec_url":"https://drafts.csswg.org/css-text/#text-align-last-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":47}],"chrome_android":[{"added":47}],"edge":[{"added":12}],"firefox":[{"added":49},{"prefix":"-moz-","added":12,"removed":53}],"firefox_android":[{"added":49},{"prefix":"-moz-","added":14,"removed":53}],"ie":[{"partial_implementation":true,"added":5.5}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}},"_aliasOf":"text-align-last"},"text-combine-upright":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-combine-upright","spec_url":"https://drafts.csswg.org/css-writing-modes/#text-combine-upright","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":48},{"alternative_name":"-webkit-text-combine","partial_implementation":true,"added":9}],"chrome_android":[{"added":48},{"alternative_name":"-webkit-text-combine","partial_implementation":true,"added":18}],"edge":[{"added":15,"removed":79},{"alternative_name":"-ms-text-combine-horizontal","added":12,"removed":79}],"firefox":[{"added":48}],"firefox_android":[{"added":48}],"ie":[{"alternative_name":"-ms-text-combine-horizontal","added":11}],"safari":[{"added":15.4},{"alternative_name":"-webkit-text-combine","partial_implementation":true,"added":5.1}],"safari_ios":[{"added":15.4},{"alternative_name":"-webkit-text-combine","partial_implementation":true,"added":5}]}},"digits":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":15,"removed":79},{"alternative_name":"-ms-text-combine-horizontal","added":12,"removed":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"alternative_name":"-ms-text-combine-horizontal","added":11}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"digits"},"_aliasOf":"text-combine-upright","-ms-text-combine-horizontal":{"_aliasOf":"digits"}},"text-decoration":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-decoration","spec_url":"https://drafts.csswg.org/css-text-decor/#text-decoration-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":3}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"blink":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":1,"removed":23}],"firefox_android":[{"added":4,"removed":23}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}},"shorthand":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":6}],"firefox_android":[{"added":6}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":8}],"safari_ios":[{"prefix":"-webkit-","added":8}]}},"_aliasOf":"shorthand"},"text-decoration-thickness":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":70}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"-webkit-shorthand":{"_aliasOf":"shorthand"}},"text-decoration-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-decoration-color","spec_url":"https://drafts.csswg.org/css-text-decor/#text-decoration-color-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":36},{"prefix":"-moz-","added":6,"removed":39}],"firefox_android":[{"added":36},{"prefix":"-moz-","added":6,"removed":39}],"ie":[{"added":false}],"safari":[{"added":12.1},{"prefix":"-webkit-","added":8}],"safari_ios":[{"added":12.2},{"prefix":"-webkit-","added":8}]}},"_aliasOf":"text-decoration-color"},"text-decoration-line":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-decoration-line","spec_url":"https://drafts.csswg.org/css-text-decor/#text-decoration-line-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":36},{"prefix":"-moz-","added":6,"removed":39}],"firefox_android":[{"added":36},{"prefix":"-moz-","added":6,"removed":39}],"ie":[{"added":false}],"safari":[{"added":12.1},{"prefix":"-webkit-","added":8}],"safari_ios":[{"added":12.2},{"prefix":"-webkit-","added":8}]}},"blink":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":26}],"firefox_android":[{"added":26}],"ie":[{"added":false}],"safari":[{"added":8}],"safari_ios":[{"added":8}]}}},"_aliasOf":"text-decoration-line"},"text-decoration-skip":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-decoration-skip","spec_url":"https://drafts.csswg.org/css-text-decor-4/#text-decoration-skipping","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":57,"removed":64}],"chrome_android":[{"added":57,"removed":64}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":12.1},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":12.2},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"text-decoration-skip"},"text-decoration-skip-ink":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-decoration-skip-ink","spec_url":"https://drafts.csswg.org/css-text-decor-4/#text-decoration-skip-ink-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":64}],"chrome_android":[{"added":64}],"edge":[{"added":79}],"firefox":[{"added":70}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}},"all":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":75}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":15.4}],"safari_ios":[{"added":15.4}]}}}},"text-decoration-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-decoration-style","spec_url":"https://drafts.csswg.org/css-text-decor/#text-decoration-style-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":36},{"prefix":"-moz-","added":6,"removed":39}],"firefox_android":[{"added":36},{"prefix":"-moz-","added":6,"removed":39}],"ie":[{"added":false}],"safari":[{"added":12.1},{"prefix":"-webkit-","added":8}],"safari_ios":[{"added":12.2},{"prefix":"-webkit-","added":8}]}},"wavy":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":57}],"chrome_android":[{"added":57}],"edge":[{"added":79}],"firefox":[{"added":6}],"firefox_android":[{"added":6}],"ie":[{"added":false}],"safari":[{"added":8}],"safari_ios":[{"added":8}]}}},"_aliasOf":"text-decoration-style"},"text-decoration-thickness":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-decoration-thickness","spec_url":"https://drafts.csswg.org/css-text-decor-4/#text-decoration-width-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":89},{"partial_implementation":true,"added":87,"removed":89}],"chrome_android":[{"added":89},{"partial_implementation":true,"added":87,"removed":89}],"edge":[{"added":89},{"partial_implementation":true,"added":87,"removed":89}],"firefox":[{"added":70}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"percentage":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":74}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"text-emphasis":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-emphasis","spec_url":"https://drafts.csswg.org/css-text-decor/#text-emphasis-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":99},{"prefix":"-webkit-","added":25}],"chrome_android":[{"added":99},{"prefix":"-webkit-","added":25}],"edge":[{"added":99},{"prefix":"-webkit-","added":79}],"firefox":[{"added":46}],"firefox_android":[{"added":46}],"ie":[{"added":false}],"safari":[{"added":7},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":7},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"text-emphasis"},"text-emphasis-color":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-emphasis-color","spec_url":"https://drafts.csswg.org/css-text-decor/#text-emphasis-color-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":99},{"prefix":"-webkit-","added":25}],"chrome_android":[{"added":99},{"prefix":"-webkit-","added":25}],"edge":[{"added":99},{"prefix":"-webkit-","added":79}],"firefox":[{"added":46}],"firefox_android":[{"added":46}],"ie":[{"added":false}],"safari":[{"added":7},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":7},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"text-emphasis-color"},"text-emphasis-position":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-emphasis-position","spec_url":"https://drafts.csswg.org/css-text-decor/#text-emphasis-position-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":99},{"prefix":"-webkit-","added":25}],"chrome_android":[{"added":99},{"prefix":"-webkit-","added":25}],"edge":[{"added":99},{"prefix":"-webkit-","added":79}],"firefox":[{"added":46}],"firefox_android":[{"added":46}],"ie":[{"added":false}],"safari":[{"added":7},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":7},{"prefix":"-webkit-","added":7}]}},"left_and_right":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":62}],"chrome_android":[{"added":62}],"edge":[{"added":79}],"firefox":[{"added":46}],"firefox_android":[{"added":46}],"ie":[{"added":false}],"safari":[{"added":8}],"safari_ios":[{"added":8}]}}},"_aliasOf":"text-emphasis-position"},"text-emphasis-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-emphasis-style","spec_url":"https://drafts.csswg.org/css-text-decor/#text-emphasis-style-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":99},{"prefix":"-webkit-","added":25}],"chrome_android":[{"added":99},{"prefix":"-webkit-","added":25}],"edge":[{"added":99},{"prefix":"-webkit-","added":79}],"firefox":[{"added":46}],"firefox_android":[{"added":46}],"ie":[{"added":false}],"safari":[{"added":7},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":7},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"text-emphasis-style"},"text-indent":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-indent","spec_url":"https://drafts.csswg.org/css-text/#text-indent-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":3}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"each-line":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"hanging":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"text-justify":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-justify","spec_url":"https://drafts.csswg.org/css-text/#text-justify-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"true"}],"added":32}],"chrome_android":[{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"true"}],"added":32}],"edge":[{"added":12}],"firefox":[{"added":55}],"firefox_android":[{"added":55}],"ie":[{"added":11}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"text-orientation":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-orientation","spec_url":"https://drafts.csswg.org/css-writing-modes/#text-orientation","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":48},{"prefix":"-webkit-","added":11}],"chrome_android":[{"added":48},{"prefix":"-webkit-","added":18}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":false}],"safari":[{"added":14},{"prefix":"-webkit-","added":5.1}],"safari_ios":[{"added":14},{"prefix":"-webkit-","added":5}]}},"sideways":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":44}],"firefox_android":[{"added":44}],"ie":[{"added":false}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"_aliasOf":"text-orientation"},"text-overflow":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-overflow","spec_url":"https://drafts.csswg.org/css-overflow/#text-overflow","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":7}],"firefox_android":[{"added":7}],"ie":[{"added":6},{"prefix":"-ms-","added":8}],"safari":[{"added":1.3}],"safari_ios":[{"added":1}]}},"string":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":9}],"firefox_android":[{"added":9}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"two_value_syntax":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":9}],"firefox_android":[{"added":9}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"text-overflow"},"text-rendering":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-rendering","spec_url":"https://svgwg.org/svg2-draft/painting.html#TextRenderingProperty","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":4}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":1}],"firefox_android":[{"added":46}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":4}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":1}],"firefox_android":[{"added":46}],"ie":[{"added":false}],"safari":[{"added":5}],"safari_ios":[{"added":4.2}]}}},"geometricPrecision":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":13}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":1}],"firefox_android":[{"added":46}],"ie":[{"added":false}],"safari":[{"added":6}],"safari_ios":[{"added":6}]}}}},"text-shadow":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-shadow","spec_url":"https://drafts.csswg.org/css-text-decor/#text-shadow-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":2}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.5}],"firefox_android":[{"added":4}],"ie":[{"added":10}],"safari":[{"added":1.1}],"safari_ios":[{"added":1}]}}},"text-size-adjust":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-size-adjust","spec_url":"https://drafts.csswg.org/css-size-adjust/#adjustment-control","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":54}],"chrome_android":[{"added":54}],"edge":[{"added":79},{"prefix":"-webkit-","added":12,"removed":79}],"firefox":[{"added":false}],"firefox_android":[{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":14}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"prefix":"-webkit-","added":1}]}},"percentages":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":54}],"chrome_android":[{"added":54}],"edge":[{"added":12}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"text-size-adjust"},"text-transform":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-transform","spec_url":"https://drafts.csswg.org/css-text/#text-transform","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"capitalize":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"dutch_ij_digraph":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":14}],"firefox_android":[{"added":14}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"full-size-kana":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":64}],"firefox_android":[{"added":64}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"full-width":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":19}],"firefox_android":[{"added":19}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"greek_accented_characters":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":34}],"chrome_android":[{"added":34}],"edge":[{"added":79}],"firefox":[{"added":15}],"firefox_android":[{"added":15}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"lowercase_sigma":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":30}],"chrome_android":[{"added":30}],"edge":[{"added":12}],"firefox":[{"added":14}],"firefox_android":[{"added":14}],"ie":[{"added":4}],"safari":[{"added":6}],"safari_ios":[{"added":6}]}}},"math-auto":{"__compat":{"spec_url":"https://w3c.github.io/mathml-core/#new-text-transform-values","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":84}],"chrome_android":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":84}],"edge":[{"added":109},{"flags":[{"name":"#enable-experimental-web-platform-features","type":"preference","value_to_set":"Enabled"}],"added":84}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"turkic_is":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":31}],"chrome_android":[{"added":31}],"edge":[{"added":12}],"firefox":[{"added":14}],"firefox_android":[{"added":14}],"ie":[{"added":4}],"safari":[{"added":8}],"safari_ios":[{"added":8}]}}},"uppercase_eszett":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":18}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}}},"text-underline-offset":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-underline-offset","spec_url":"https://drafts.csswg.org/css-text-decor-4/#underline-offset","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":70}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}},"percentage":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":74}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}}},"text-underline-position":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-underline-position","spec_url":"https://drafts.csswg.org/css-text-decor/#text-underline-position-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":33}],"chrome_android":[{"added":33}],"edge":[{"added":12}],"firefox":[{"added":74}],"firefox_android":[{"added":79}],"ie":[{"added":6}],"safari":[{"added":12.1},{"prefix":"-webkit-","added":9}],"safari_ios":[{"added":12.2},{"prefix":"-webkit-","added":9}]}},"above_below":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79}],"firefox":[{"added":74}],"firefox_android":[{"added":false}],"ie":[{"added":6}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"auto-pos":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":6}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"from-font":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":87}],"chrome_android":[{"added":87}],"edge":[{"added":87}],"firefox":[{"added":74}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}},"left_right":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":71}],"chrome_android":[{"added":71}],"edge":[{"added":79}],"firefox":[{"added":74}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"under":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":33}],"chrome_android":[{"added":33}],"edge":[{"added":79}],"firefox":[{"added":74}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":12.1}],"safari_ios":[{"added":12.2}]}}},"_aliasOf":"text-underline-position"},"text-wrap":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/text-wrap","spec_url":"https://drafts.csswg.org/css-text-4/#text-wrap","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"partial_implementation":true,"added":114}],"chrome_android":[{"partial_implementation":true,"added":114}],"edge":[{"partial_implementation":true,"added":114}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"top":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/top","spec_url":"https://drafts.csswg.org/css-position/#insets","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"touch-action":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/touch-action","spec_url":["https://compat.spec.whatwg.org/#touch-action","https://w3c.github.io/pointerevents/#the-touch-action-css-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":36}],"chrome_android":[{"added":36}],"edge":[{"added":12}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":11},{"prefix":"-ms-","added":10}],"safari":[{"added":13}],"safari_ios":[{"added":9.3}]}},"axis-pan":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":36}],"chrome_android":[{"added":36}],"edge":[{"added":12}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":11},{"prefix":"-ms-","added":10}],"safari":[{"added":13}],"safari_ios":[{"added":13}]}},"_aliasOf":"axis-pan"},"double-tap-zoom":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":11},{"prefix":"-ms-","added":10}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"double-tap-zoom"},"manipulation":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":36}],"chrome_android":[{"added":36}],"edge":[{"added":12}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":11},{"prefix":"-ms-","added":10}],"safari":[{"added":13}],"safari_ios":[{"added":9.3}]}},"_aliasOf":"manipulation"},"none":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":36}],"chrome_android":[{"added":36}],"edge":[{"added":12}],"firefox":[{"added":52}],"firefox_android":[{"added":52}],"ie":[{"added":11},{"prefix":"-ms-","added":10}],"safari":[{"added":13}],"safari_ios":[{"added":13}]}},"_aliasOf":"none"},"pinch-zoom":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":56}],"chrome_android":[{"added":56}],"edge":[{"added":12}],"firefox":[{"added":85}],"firefox_android":[{"added":85}],"ie":[{"added":11},{"prefix":"-ms-","added":10}],"safari":[{"added":13}],"safari_ios":[{"added":13}]}},"_aliasOf":"pinch-zoom"},"unidirectional-pan":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":55}],"chrome_android":[{"added":55}],"edge":[{"added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"_aliasOf":"touch-action","-ms-axis-pan":{"_aliasOf":"axis-pan"},"-ms-double-tap-zoom":{"_aliasOf":"double-tap-zoom"},"-ms-manipulation":{"_aliasOf":"manipulation"},"-ms-none":{"_aliasOf":"none"},"-ms-pinch-zoom":{"_aliasOf":"pinch-zoom"}},"transform":{"3d":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":12}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":16}],"firefox_android":[{"added":16}],"ie":[{"added":10}],"safari":[{"added":4}],"safari_ios":[{"added":3.2}]}}},"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/transform","spec_url":["https://drafts.csswg.org/css-transforms-2/#transform-functions","https://drafts.csswg.org/css-transforms/#transform-property"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":36},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":36},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49}],"ie":[{"added":10},{"prefix":"-webkit-","added":11},{"prefix":"-ms-","added":9}],"safari":[{"added":9},{"prefix":"-webkit-","added":3.1}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":3.2}]}},"_aliasOf":"transform"},"transform-box":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/transform-box","spec_url":"https://drafts.csswg.org/css-transforms/#transform-box","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":64}],"chrome_android":[{"added":64}],"edge":[{"added":79}],"firefox":[{"added":55}],"firefox_android":[{"added":55}],"ie":[{"added":false}],"safari":[{"added":11}],"safari_ios":[{"added":11}]}}},"transform-origin":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/transform-origin","spec_url":"https://drafts.csswg.org/css-transforms/#transform-origin-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":36},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":36},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":3.5}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":10},{"prefix":"-ms-","added":9}],"safari":[{"added":9},{"prefix":"-webkit-","added":2}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":1}]}},"support_in_svg":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":19}],"chrome_android":[{"added":25}],"edge":[{"added":17}],"firefox":[{"added":43}],"firefox_android":[{"added":43}],"ie":[{"added":false}],"safari":[{"added":6}],"safari_ios":[{"added":6}]}}},"three_value_syntax":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":12}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":10}],"firefox_android":[{"added":10}],"ie":[{"added":9}],"safari":[{"added":5}],"safari_ios":[{"added":3.2}]}}},"_aliasOf":"transform-origin"},"transform-style":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/transform-style","spec_url":"https://drafts.csswg.org/css-transforms-2/#transform-style-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":36},{"prefix":"-webkit-","added":12}],"chrome_android":[{"added":36},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":10}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":10}],"ie":[{"added":false}],"safari":[{"added":9},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":2}]}},"_aliasOf":"transform-style"},"transition":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/transition","spec_url":"https://drafts.csswg.org/css-transitions/#transition-shorthand-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":26},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":26},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":10},{"prefix":"-ms-","added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":3.1}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":2}]}},"gradients":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":10}],"safari":[{"added":null}],"safari_ios":[{"added":false}]}}},"_aliasOf":"transition"},"transition-delay":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/transition-delay","spec_url":"https://drafts.csswg.org/css-transitions/#transition-delay-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":26},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":26},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":10},{"prefix":"-ms-","added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":4}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":2}]}},"_aliasOf":"transition-delay"},"transition-duration":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/transition-duration","spec_url":"https://drafts.csswg.org/css-transitions/#transition-duration-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":26},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":26},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":10},{"prefix":"-ms-","added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":3.1}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":2}]}},"_aliasOf":"transition-duration"},"transition-property":{"IDENT_value":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":16}],"firefox_android":[{"added":16}],"ie":[{"added":10}],"safari":[{"added":4}],"safari_ios":[{"added":3}]}}},"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/transition-property","spec_url":"https://drafts.csswg.org/css-transitions/#transition-property-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":26},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":26},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":10},{"prefix":"-ms-","added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":3.1}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":2}]}},"_aliasOf":"transition-property"},"transition-timing-function":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/transition-timing-function","spec_url":"https://drafts.csswg.org/css-transitions/#transition-timing-function-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":26},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":26},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"firefox_android":[{"added":16},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"added":10},{"prefix":"-ms-","added":10}],"safari":[{"added":9},{"prefix":"-webkit-","added":3.1}],"safari_ios":[{"added":9},{"prefix":"-webkit-","added":2}]}},"jump":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":77}],"chrome_android":[{"added":77}],"edge":[{"added":79}],"firefox":[{"added":65}],"firefox_android":[{"added":65}],"ie":[{"added":false}],"safari":[{"added":14}],"safari_ios":[{"added":14}]}}},"_aliasOf":"transition-timing-function"},"translate":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/translate","spec_url":"https://drafts.csswg.org/css-transforms-2/#individual-transforms","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":104}],"chrome_android":[{"added":104}],"edge":[{"added":104}],"firefox":[{"added":72}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":14.1}],"safari_ios":[{"added":14.5}]}}},"unicode-bidi":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/unicode-bidi","spec_url":"https://drafts.csswg.org/css-writing-modes/#unicode-bidi","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":2}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5.5}],"safari":[{"added":1.3}],"safari_ios":[{"added":1}]}},"isolate":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":48},{"prefix":"-webkit-","added":16}],"chrome_android":[{"added":48}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":50},{"prefix":"-moz-","added":10,"removed":54}],"firefox_android":[{"added":50},{"prefix":"-moz-","added":10,"removed":54}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":6}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":6}]}},"_aliasOf":"isolate"},"isolate-override":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":48}],"chrome_android":[{"added":48}],"edge":[{"added":79}],"firefox":[{"added":50},{"prefix":"-moz-","added":17,"removed":54}],"firefox_android":[{"added":50},{"prefix":"-moz-","added":17,"removed":54}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":7}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":7}]}},"_aliasOf":"isolate-override"},"plaintext":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":48}],"chrome_android":[{"added":48}],"edge":[{"added":79}],"firefox":[{"added":50},{"prefix":"-moz-","added":10,"removed":54}],"firefox_android":[{"added":50},{"prefix":"-moz-","added":10,"removed":54}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":6}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":6}]}},"_aliasOf":"plaintext"},"-webkit-isolate":{"_aliasOf":"isolate"},"-moz-isolate":{"_aliasOf":"isolate"},"-moz-isolate-override":{"_aliasOf":"isolate-override"},"-webkit-isolate-override":{"_aliasOf":"isolate-override"},"-moz-plaintext":{"_aliasOf":"plaintext"},"-webkit-plaintext":{"_aliasOf":"plaintext"}},"user-modify":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/user-modify","status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"prefix":"-webkit-","added":1}],"chrome_android":[{"prefix":"-webkit-","added":18}],"edge":[{"prefix":"-webkit-","added":12}],"firefox":[{"partial_implementation":true,"prefix":"-moz-","added":1}],"firefox_android":[{"partial_implementation":true,"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"prefix":"-webkit-","added":3},{"prefix":"-khtml-","added":2,"removed":3}],"safari_ios":[{"prefix":"-webkit-","added":5}]}},"read-write-plaintext-only":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":5}]}}},"_aliasOf":"user-modify"},"user-select":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/user-select","spec_url":"https://drafts.csswg.org/css-ui/#content-selection","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":54},{"prefix":"-webkit-","added":1}],"chrome_android":[{"added":54},{"prefix":"-webkit-","added":18}],"edge":[{"added":79},{"prefix":"-webkit-","added":12},{"prefix":"-ms-","added":12,"removed":79}],"firefox":[{"added":69},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":1}],"firefox_android":[{"added":79},{"prefix":"-webkit-","added":49},{"prefix":"-moz-","added":4}],"ie":[{"prefix":"-ms-","added":10}],"safari":[{"prefix":"-webkit-","added":3},{"prefix":"-khtml-","added":2,"removed":3}],"safari_ios":[{"prefix":"-webkit-","added":3}]}},"all":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":53}],"chrome_android":[{"added":53}],"edge":[{"added":79}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":false}],"safari":[{"added":16}],"safari_ios":[{"added":16}]}}},"auto":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":10}],"safari":[{"added":2}],"safari_ios":[{"added":3}]}}},"contain":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"alternative_name":"element","added":12,"removed":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"alternative_name":"element","added":10}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}},"_aliasOf":"contain"},"none":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":21},{"prefix":"-moz-","added":1,"removed":65}],"firefox_android":[{"added":21},{"prefix":"-moz-","added":4,"removed":65}],"ie":[{"added":10}],"safari":[{"added":2}],"safari_ios":[{"added":3}]}},"_aliasOf":"none"},"text":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":10}],"safari":[{"partial_implementation":true,"added":2}],"safari_ios":[{"partial_implementation":true,"added":3}]}}},"_aliasOf":"user-select","element":{"_aliasOf":"contain"},"-moz-none":{"_aliasOf":"none"}},"vertical-align":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/vertical-align","spec_url":"https://drafts.csswg.org/css2/#propdef-vertical-align","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"view-transition-name":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/view-transition-name","spec_url":"https://drafts.csswg.org/css-view-transitions/#view-transition-name-prop","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":111}],"chrome_android":[{"added":111}],"edge":[{"added":111}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"visibility":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/visibility","spec_url":"https://drafts.csswg.org/css-display/#visibility","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"collapse":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":10}],"safari":[{"added":1.3}],"safari_ios":[{"added":1}]}}}},"white-space":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/white-space","spec_url":"https://drafts.csswg.org/css-text/#white-space-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"break-spaces":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":76}],"chrome_android":[{"added":76}],"edge":[{"added":79}],"firefox":[{"added":69}],"firefox_android":[{"added":79}],"ie":[{"added":false}],"safari":[{"added":13.1}],"safari_ios":[{"added":13.4}]}}},"nowrap":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"pre":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":6}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"pre-line":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3.5}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}}},"pre-wrap":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3},{"prefix":"-moz-","added":1,"removed":3.6}],"firefox_android":[{"added":4}],"ie":[{"added":8}],"safari":[{"added":3}],"safari_ios":[{"added":1}]}},"_aliasOf":"pre-wrap"},"shorthand_values":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"partial_implementation":true,"added":114}],"chrome_android":[{"partial_implementation":true,"added":114}],"edge":[{"partial_implementation":true,"added":114}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"svg_support":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":12,"removed":79}],"firefox":[{"added":36}],"firefox_android":[{"added":36}],"ie":[{"added":10}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"textarea_support":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":36}],"firefox_android":[{"added":36}],"ie":[{"added":5.5}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}},"-moz-pre-wrap":{"_aliasOf":"pre-wrap"}},"white-space-collapse":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/white-space-collapse","spec_url":"https://drafts.csswg.org/css-text-4/#white-space-collapsing","status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"partial_implementation":true,"added":114}],"chrome_android":[{"partial_implementation":true,"added":114}],"edge":[{"partial_implementation":true,"added":114}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"widows":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/widows","spec_url":["https://drafts.csswg.org/css-break/#widows-orphans","https://drafts.csswg.org/css-multicol/#filling-columns"],"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":25}],"chrome_android":[{"added":25}],"edge":[{"added":12}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":8}],"safari":[{"added":1.3}],"safari_ios":[{"added":1}]}}},"width":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/width","spec_url":"https://drafts.csswg.org/css-sizing-4/#width-height-keywords","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"animatable":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":26}],"chrome_android":[{"added":26}],"edge":[{"added":12}],"firefox":[{"added":16}],"firefox_android":[{"added":16}],"ie":[{"added":11}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"fill":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":46}],"chrome_android":[{"added":46}],"edge":[{"added":79}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":12}],"safari_ios":[{"added":12}]}}},"fit-content":{"__compat":{"spec_url":"https://drafts.csswg.org/css-sizing-4/#valdef-width-fit-content","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46},{"prefix":"-webkit-","added":22},{"alternative_name":"intrinsic","added":1,"removed":48}],"chrome_android":[{"added":46},{"prefix":"-webkit-","added":25},{"alternative_name":"intrinsic","added":18,"removed":48}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":94},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":94},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"prefix":"-webkit-","added":7},{"alternative_name":"intrinsic","added":2}],"safari_ios":[{"added":11},{"prefix":"-webkit-","added":7},{"alternative_name":"intrinsic","added":1}]}},"_aliasOf":"fit-content"},"fit-content_function":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"flags":[{"name":"layout.css.fit-content-function.enabled","type":"preference"}],"added":91}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"max-content":{"__compat":{"spec_url":"https://drafts.csswg.org/css-sizing-3/#valdef-width-max-content","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46},{"prefix":"-webkit-","added":22}],"chrome_android":[{"added":46}],"edge":[{"added":79},{"prefix":"-webkit-","added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"alternative_name":"intrinsic","added":2}],"safari_ios":[{"added":11},{"alternative_name":"intrinsic","added":1}]}},"_aliasOf":"max-content"},"min-content":{"__compat":{"spec_url":"https://drafts.csswg.org/css-sizing-3/#valdef-width-min-content","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":46},{"alternative_name":"min-intrinsic","added":1,"removed":48}],"chrome_android":[{"added":46},{"alternative_name":"min-intrinsic","added":18,"removed":48}],"edge":[{"added":79}],"firefox":[{"added":66},{"prefix":"-moz-","added":3}],"firefox_android":[{"added":66},{"prefix":"-moz-","added":4}],"ie":[{"added":false}],"safari":[{"added":11},{"alternative_name":"min-intrinsic","added":2}],"safari_ios":[{"added":11},{"alternative_name":"min-intrinsic","added":1}]}},"_aliasOf":"min-content"},"stretch":{"__compat":{"status":{"deprecated":false,"experimental":true,"standard_track":true},"support":{"chrome":[{"alternative_name":"-webkit-fill-available","added":22}],"chrome_android":[{"alternative_name":"-webkit-fill-available","added":25}],"edge":[{"alternative_name":"-webkit-fill-available","added":79}],"firefox":[{"alternative_name":"-moz-available","added":3}],"firefox_android":[{"alternative_name":"-moz-available","added":4}],"ie":[{"added":false}],"safari":[{"alternative_name":"-webkit-fill-available","added":7}],"safari_ios":[{"alternative_name":"-webkit-fill-available","added":7}]}},"_aliasOf":"stretch"},"-webkit-fit-content":{"_aliasOf":"fit-content"},"intrinsic":{"_aliasOf":"max-content"},"-moz-fit-content":{"_aliasOf":"fit-content"},"-webkit-max-content":{"_aliasOf":"max-content"},"-moz-max-content":{"_aliasOf":"max-content"},"min-intrinsic":{"_aliasOf":"min-content"},"-moz-min-content":{"_aliasOf":"min-content"},"-webkit-fill-available":{"_aliasOf":"stretch"},"-moz-available":{"_aliasOf":"stretch"}},"will-change":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/will-change","spec_url":"https://drafts.csswg.org/css-will-change/#will-change","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":36}],"chrome_android":[{"added":36}],"edge":[{"added":79}],"firefox":[{"added":36}],"firefox_android":[{"added":36}],"ie":[{"added":false}],"safari":[{"added":9.1}],"safari_ios":[{"added":9.3}]}}},"word-break":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/word-break","spec_url":"https://drafts.csswg.org/css-text/#word-break-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":15}],"firefox_android":[{"added":15}],"ie":[{"added":5.5},{"prefix":"-ms-","added":8}],"safari":[{"added":3}],"safari_ios":[{"added":2}]}},"break-word":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":79}],"firefox":[{"added":67}],"firefox_android":[{"added":67}],"ie":[{"added":false}],"safari":[{"added":3}],"safari_ios":[{"added":2}]}}},"keep-all":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":44}],"chrome_android":[{"added":44}],"edge":[{"added":12}],"firefox":[{"added":15}],"firefox_android":[{"added":15}],"ie":[{"added":5.5}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}}},"_aliasOf":"word-break"},"word-spacing":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/word-spacing","spec_url":"https://drafts.csswg.org/css-text/#word-spacing-property","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":6}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"percentages":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":45}],"firefox_android":[{"added":45}],"ie":[{"added":false}],"safari":[{"added":7}],"safari_ios":[{"added":7}]}}},"svg":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":72}],"firefox_android":[{"added":false}],"ie":[{"added":9}],"safari":[{"added":5.1}],"safari_ios":[{"added":5}]}}}},"writing-mode":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/writing-mode","spec_url":"https://drafts.csswg.org/css-writing-modes/#block-flow","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":48},{"prefix":"-webkit-","added":8}],"chrome_android":[{"added":48},{"prefix":"-webkit-","added":18}],"edge":[{"added":12},{"prefix":"-webkit-","added":12}],"firefox":[{"added":41}],"firefox_android":[{"added":41}],"ie":[{"added":9},{"prefix":"-ms-","added":9}],"safari":[{"added":10.1},{"prefix":"-webkit-","added":5.1}],"safari_ios":[{"added":10.3},{"prefix":"-webkit-","added":5}]}},"horizontal_vertical_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":48}],"chrome_android":[{"added":48}],"edge":[{"added":79}],"firefox":[{"added":43}],"firefox_android":[{"added":43}],"ie":[{"added":false}],"safari":[{"added":9}],"safari_ios":[{"added":9}]}}},"sideways_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":false}],"chrome_android":[{"added":false}],"edge":[{"added":false}],"firefox":[{"added":43}],"firefox_android":[{"added":43}],"ie":[{"added":false}],"safari":[{"added":false}],"safari_ios":[{"added":false}]}}},"svg_values":{"__compat":{"status":{"deprecated":true,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":48}],"chrome_android":[{"added":48}],"edge":[{"added":12}],"firefox":[{"added":43}],"firefox_android":[{"added":43}],"ie":[{"added":9}],"safari":[{"added":10.1}],"safari_ios":[{"added":10.3}]}}},"_aliasOf":"writing-mode"},"z-index":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/z-index","spec_url":"https://drafts.csswg.org/css2/#z-index","status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":1}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}},"negative_values":{"__compat":{"status":{"deprecated":false,"experimental":false,"standard_track":true},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":3}],"firefox_android":[{"added":4}],"ie":[{"added":4}],"safari":[{"added":1}],"safari_ios":[{"added":1}]}}}},"zoom":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/zoom","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":1}],"chrome_android":[{"added":18}],"edge":[{"added":12}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":5.5}],"safari":[{"added":3.1}],"safari_ios":[{"added":3}]}},"reset":{"__compat":{"mdn_url":"https://developer.mozilla.org/docs/Web/CSS/zoom#Values","status":{"deprecated":false,"experimental":false,"standard_track":false},"support":{"chrome":[{"added":1,"removed":59}],"chrome_android":[{"added":18,"removed":59}],"edge":[{"added":false}],"firefox":[{"added":false}],"firefox_android":[{"added":false}],"ie":[{"added":false}],"safari":[{"added":3.1}],"safari_ios":[{"added":3}]}}}},"-webkit-align-content":{"_aliasOf":"align-content"},"-webkit-align-items":{"_aliasOf":"align-items"},"-webkit-align-self":{"_aliasOf":"align-self"},"-webkit-alt":{"_aliasOf":"alt"},"-webkit-animation":{"_aliasOf":"animation"},"-moz-animation":{"_aliasOf":"animation"},"-o-animation":{"_aliasOf":"animation"},"-webkit-animation-delay":{"_aliasOf":"animation-delay"},"-moz-animation-delay":{"_aliasOf":"animation-delay"},"-o-animation-delay":{"_aliasOf":"animation-delay"},"-webkit-animation-direction":{"_aliasOf":"animation-direction"},"-moz-animation-direction":{"_aliasOf":"animation-direction"},"-o-animation-direction":{"_aliasOf":"animation-direction"},"-webkit-animation-duration":{"_aliasOf":"animation-duration"},"-moz-animation-duration":{"_aliasOf":"animation-duration"},"-o-animation-duration":{"_aliasOf":"animation-duration"},"-webkit-animation-fill-mode":{"_aliasOf":"animation-fill-mode"},"-moz-animation-fill-mode":{"_aliasOf":"animation-fill-mode"},"-o-animation-fill-mode":{"_aliasOf":"animation-fill-mode"},"-webkit-animation-iteration-count":{"_aliasOf":"animation-iteration-count"},"-moz-animation-iteration-count":{"_aliasOf":"animation-iteration-count"},"-o-animation-iteration-count":{"_aliasOf":"animation-iteration-count"},"-webkit-animation-name":{"_aliasOf":"animation-name"},"-moz-animation-name":{"_aliasOf":"animation-name"},"-o-animation-name":{"_aliasOf":"animation-name"},"-webkit-animation-play-state":{"_aliasOf":"animation-play-state"},"-moz-animation-play-state":{"_aliasOf":"animation-play-state"},"-o-animation-play-state":{"_aliasOf":"animation-play-state"},"-webkit-animation-timing-function":{"_aliasOf":"animation-timing-function"},"-moz-animation-timing-function":{"_aliasOf":"animation-timing-function"},"-o-animation-timing-function":{"_aliasOf":"animation-timing-function"},"-webkit-appearance":{"_aliasOf":"appearance"},"-moz-appearance":{"_aliasOf":"appearance"},"-webkit-backdrop-filter":{"_aliasOf":"backdrop-filter"},"-webkit-backface-visibility":{"_aliasOf":"backface-visibility"},"-moz-backface-visibility":{"_aliasOf":"backface-visibility"},"-webkit-background-clip":{"_aliasOf":"background-clip"},"-moz-background-clip":{"_aliasOf":"background-clip"},"-webkit-background-origin":{"_aliasOf":"background-origin"},"-moz-background-origin":{"_aliasOf":"background-origin"},"-webkit-background-size":{"_aliasOf":"background-size"},"-moz-background-size":{"_aliasOf":"background-size"},"-o-background-size":{"_aliasOf":"background-size"},"-webkit-border-bottom-left-radius":{"_aliasOf":"border-bottom-left-radius"},"-moz-border-radius-bottomleft":{"_aliasOf":"border-bottom-left-radius"},"-webkit-border-bottom-right-radius":{"_aliasOf":"border-bottom-right-radius"},"-moz-border-radius-bottomright":{"_aliasOf":"border-bottom-right-radius"},"-webkit-border-image":{"_aliasOf":"border-image"},"-moz-border-image":{"_aliasOf":"border-image"},"-o-border-image":{"_aliasOf":"border-image"},"-webkit-border-image-slice":{"_aliasOf":"border-image-slice"},"-moz-border-end-color":{"_aliasOf":"border-inline-end-color"},"-moz-border-end-style":{"_aliasOf":"border-inline-end-style"},"-moz-border-end-width":{"_aliasOf":"border-inline-end-width"},"-moz-border-start-color":{"_aliasOf":"border-inline-start-color"},"-moz-border-start-style":{"_aliasOf":"border-inline-start-style"},"-webkit-border-radius":{"_aliasOf":"border-radius"},"-moz-border-radius":{"_aliasOf":"border-radius"},"-webkit-border-top-left-radius":{"_aliasOf":"border-top-left-radius"},"-moz-border-radius-topleft":{"_aliasOf":"border-top-left-radius"},"-webkit-border-top-right-radius":{"_aliasOf":"border-top-right-radius"},"-moz-border-radius-topright":{"_aliasOf":"border-top-right-radius"},"-webkit-box-align":{"_aliasOf":"box-align"},"-moz-box-align":{"_aliasOf":"box-align"},"-khtml-box-align":{"_aliasOf":"box-align"},"-webkit-box-decoration-break":{"_aliasOf":"box-decoration-break"},"-moz-background-inline-policy":{"_aliasOf":"box-decoration-break"},"-webkit-box-direction":{"_aliasOf":"box-direction"},"-moz-box-direction":{"_aliasOf":"box-direction"},"-khtml-box-direction":{"_aliasOf":"box-direction"},"-webkit-box-flex":{"_aliasOf":"box-flex"},"-moz-box-flex":{"_aliasOf":"box-flex"},"-khtml-box-flex":{"_aliasOf":"box-flex"},"-webkit-box-flex-group":{"_aliasOf":"box-flex-group"},"-khtml-box-flex-group":{"_aliasOf":"box-flex-group"},"-webkit-box-lines":{"_aliasOf":"box-lines"},"-khtml-box-lines":{"_aliasOf":"box-lines"},"-webkit-box-ordinal-group":{"_aliasOf":"box-ordinal-group"},"-moz-box-ordinal-group":{"_aliasOf":"box-ordinal-group"},"-khtml-box-ordinal-group":{"_aliasOf":"box-ordinal-group"},"-webkit-box-orient":{"_aliasOf":"box-orient"},"-moz-box-orient":{"_aliasOf":"box-orient"},"-khtml-box-orient":{"_aliasOf":"box-orient"},"-webkit-box-pack":{"_aliasOf":"box-pack"},"-moz-box-pack":{"_aliasOf":"box-pack"},"-khtml-box-pack":{"_aliasOf":"box-pack"},"-webkit-box-shadow":{"_aliasOf":"box-shadow"},"-moz-box-shadow":{"_aliasOf":"box-shadow"},"-webkit-box-sizing":{"_aliasOf":"box-sizing"},"-moz-box-sizing":{"_aliasOf":"box-sizing"},"-webkit-clip-path":{"_aliasOf":"clip-path"},"-webkit-column-count":{"_aliasOf":"column-count"},"-moz-column-count":{"_aliasOf":"column-count"},"-moz-column-fill":{"_aliasOf":"column-fill"},"-webkit-column-fill":{"_aliasOf":"column-fill"},"-webkit-column-rule":{"_aliasOf":"column-rule"},"-moz-column-rule":{"_aliasOf":"column-rule"},"-webkit-column-rule-color":{"_aliasOf":"column-rule-color"},"-moz-column-rule-color":{"_aliasOf":"column-rule-color"},"-webkit-column-rule-style":{"_aliasOf":"column-rule-style"},"-moz-column-rule-style":{"_aliasOf":"column-rule-style"},"-webkit-column-rule-width":{"_aliasOf":"column-rule-width"},"-moz-column-rule-width":{"_aliasOf":"column-rule-width"},"-webkit-column-span":{"_aliasOf":"column-span"},"-webkit-column-width":{"_aliasOf":"column-width"},"-moz-column-width":{"_aliasOf":"column-width"},"-webkit-columns":{"_aliasOf":"columns"},"-moz-columns":{"_aliasOf":"columns"},"-webkit-filter":{"_aliasOf":"filter"},"-webkit-flex":{"_aliasOf":"flex"},"-ms-flex":{"_aliasOf":"flex"},"-webkit-flex-basis":{"_aliasOf":"flex-basis"},"-webkit-flex-direction":{"_aliasOf":"flex-direction"},"-ms-flex-direction":{"_aliasOf":"flex-direction"},"-webkit-flex-flow":{"_aliasOf":"flex-flow"},"-webkit-flex-grow":{"_aliasOf":"flex-grow"},"-ms-flex-positive":{"_aliasOf":"flex-grow"},"-webkit-flex-shrink":{"_aliasOf":"flex-shrink"},"-webkit-flex-wrap":{"_aliasOf":"flex-wrap"},"-webkit-font-feature-settings":{"_aliasOf":"font-feature-settings"},"-moz-font-feature-settings":{"_aliasOf":"font-feature-settings"},"-webkit-font-kerning":{"_aliasOf":"font-kerning"},"-moz-font-language-override":{"_aliasOf":"font-language-override"},"-webkit-font-smoothing":{"_aliasOf":"font-smooth"},"-moz-osx-font-smoothing":{"_aliasOf":"font-smooth"},"-webkit-font-variant-ligatures":{"_aliasOf":"font-variant-ligatures"},"-ms-high-contrast-adjust":{"_aliasOf":"forced-color-adjust"},"-ms-grid-columns":{"_aliasOf":"grid-template-columns"},"-ms-grid-rows":{"_aliasOf":"grid-template-rows"},"-webkit-hyphenate-character":{"_aliasOf":"hyphenate-character"},"-webkit-hyphens":{"_aliasOf":"hyphens"},"-ms-hyphens":{"_aliasOf":"hyphens"},"-moz-hyphens":{"_aliasOf":"hyphens"},"-ms-ime-mode":{"_aliasOf":"ime-mode"},"-webkit-initial-letter":{"_aliasOf":"initial-letter"},"offset-block":{"_aliasOf":"inset-block"},"offset-block-end":{"_aliasOf":"inset-block-end"},"offset-block-start":{"_aliasOf":"inset-block-start"},"offset-inline":{"_aliasOf":"inset-inline"},"offset-inline-end":{"_aliasOf":"inset-inline-end"},"offset-inline-start":{"_aliasOf":"inset-inline-start"},"-webkit-justify-content":{"_aliasOf":"justify-content"},"-webkit-line-break":{"_aliasOf":"line-break"},"-ms-line-break":{"_aliasOf":"line-break"},"-khtml-line-break":{"_aliasOf":"line-break"},"-webkit-margin-end":{"_aliasOf":"margin-inline-end"},"-moz-margin-end":{"_aliasOf":"margin-inline-end"},"-webkit-margin-start":{"_aliasOf":"margin-inline-start"},"-moz-margin-start":{"_aliasOf":"margin-inline-start"},"-webkit-mask":{"_aliasOf":"mask"},"-webkit-mask-box-image-outset":{"_aliasOf":"mask-border-outset"},"-webkit-mask-box-image-repeat":{"_aliasOf":"mask-border-repeat"},"-webkit-mask-box-image-slice":{"_aliasOf":"mask-border-slice"},"-webkit-mask-box-image-source":{"_aliasOf":"mask-border-source"},"-webkit-mask-box-image-width":{"_aliasOf":"mask-border-width"},"-webkit-mask-clip":{"_aliasOf":"mask-clip"},"-webkit-mask-image":{"_aliasOf":"mask-image"},"-webkit-mask-origin":{"_aliasOf":"mask-origin"},"-webkit-mask-position":{"_aliasOf":"mask-position"},"-webkit-mask-repeat":{"_aliasOf":"mask-repeat"},"-webkit-mask-size":{"_aliasOf":"mask-size"},"-webkit-max-inline-size":{"_aliasOf":"max-inline-size"},"-o-object-fit":{"_aliasOf":"object-fit"},"-o-object-position":{"_aliasOf":"object-position"},"motion":{"_aliasOf":"offset"},"motion-distance":{"_aliasOf":"offset-distance"},"motion-path":{"_aliasOf":"offset-path"},"offset-rotation":{"_aliasOf":"offset-rotate"},"motion-rotation":{"_aliasOf":"offset-rotate"},"-moz-opacity":{"_aliasOf":"opacity"},"-khtml-opacity":{"_aliasOf":"opacity"},"-webkit-order":{"_aliasOf":"order"},"-ms-order":{"_aliasOf":"order"},"-moz-outline":{"_aliasOf":"outline"},"-moz-outline-color":{"_aliasOf":"outline-color"},"-moz-outline-style":{"_aliasOf":"outline-style"},"-moz-outline-width":{"_aliasOf":"outline-width"},"word-wrap":{"_aliasOf":"overflow-wrap"},"-ms-overflow-x":{"_aliasOf":"overflow-x"},"-ms-overflow-y":{"_aliasOf":"overflow-y"},"-webkit-padding-end":{"_aliasOf":"padding-inline-end"},"-moz-padding-end":{"_aliasOf":"padding-inline-end"},"-webkit-padding-start":{"_aliasOf":"padding-inline-start"},"-moz-padding-start":{"_aliasOf":"padding-inline-start"},"-webkit-perspective":{"_aliasOf":"perspective"},"-moz-perspective":{"_aliasOf":"perspective"},"-webkit-perspective-origin":{"_aliasOf":"perspective-origin"},"-moz-perspective-origin":{"_aliasOf":"perspective-origin"},"-webkit-print-color-adjust":{"_aliasOf":"print-color-adjust"},"color-adjust":{"_aliasOf":"print-color-adjust"},"-webkit-ruby-position":{"_aliasOf":"ruby-position"},"scroll-snap-margin":{"_aliasOf":"scroll-margin"},"scroll-snap-margin-bottom":{"_aliasOf":"scroll-margin-bottom"},"scroll-snap-margin-left":{"_aliasOf":"scroll-margin-left"},"scroll-snap-margin-right":{"_aliasOf":"scroll-margin-right"},"scroll-snap-margin-top":{"_aliasOf":"scroll-margin-top"},"-webkit-scroll-snap-points-x":{"_aliasOf":"scroll-snap-points-x"},"-webkit-scroll-snap-points-y":{"_aliasOf":"scroll-snap-points-y"},"-ms-scroll-snap-type":{"_aliasOf":"scroll-snap-type"},"-webkit-scroll-snap-type":{"_aliasOf":"scroll-snap-type"},"-ms-scrollbar-3dlight-color":{"_aliasOf":"scrollbar-3dlight-color"},"-ms-scrollbar-arrow-color":{"_aliasOf":"scrollbar-arrow-color"},"-ms-scrollbar-base-color":{"_aliasOf":"scrollbar-base-color"},"-ms-scrollbar-darkshadow-color":{"_aliasOf":"scrollbar-darkshadow-color"},"-ms-scrollbar-face-color":{"_aliasOf":"scrollbar-face-color"},"-ms-scrollbar-highlight-color":{"_aliasOf":"scrollbar-highlight-color"},"-ms-scrollbar-shadow-color":{"_aliasOf":"scrollbar-shadow-color"},"-webkit-shape-margin":{"_aliasOf":"shape-margin"},"-moz-tab-size":{"_aliasOf":"tab-size"},"-o-tab-size":{"_aliasOf":"tab-size"},"-moz-text-align-last":{"_aliasOf":"text-align-last"},"-webkit-text-combine":{"_aliasOf":"text-combine-upright"},"-ms-text-combine-horizontal":{"_aliasOf":"text-combine-upright"},"-moz-text-decoration-color":{"_aliasOf":"text-decoration-color"},"-webkit-text-decoration-color":{"_aliasOf":"text-decoration-color"},"-moz-text-decoration-line":{"_aliasOf":"text-decoration-line"},"-webkit-text-decoration-line":{"_aliasOf":"text-decoration-line"},"-webkit-text-decoration-skip":{"_aliasOf":"text-decoration-skip"},"-moz-text-decoration-style":{"_aliasOf":"text-decoration-style"},"-webkit-text-decoration-style":{"_aliasOf":"text-decoration-style"},"-webkit-text-emphasis":{"_aliasOf":"text-emphasis"},"-webkit-text-emphasis-color":{"_aliasOf":"text-emphasis-color"},"-webkit-text-emphasis-position":{"_aliasOf":"text-emphasis-position"},"-webkit-text-emphasis-style":{"_aliasOf":"text-emphasis-style"},"-webkit-text-orientation":{"_aliasOf":"text-orientation"},"-ms-text-overflow":{"_aliasOf":"text-overflow"},"-o-text-overflow":{"_aliasOf":"text-overflow"},"-webkit-text-size-adjust":{"_aliasOf":"text-size-adjust"},"-moz-text-size-adjust":{"_aliasOf":"text-size-adjust"},"-webkit-text-underline-position":{"_aliasOf":"text-underline-position"},"-ms-touch-action":{"_aliasOf":"touch-action"},"-webkit-transform":{"_aliasOf":"transform"},"-ms-transform":{"_aliasOf":"transform"},"-o-transform":{"_aliasOf":"transform"},"-webkit-transform-origin":{"_aliasOf":"transform-origin"},"-moz-transform-origin":{"_aliasOf":"transform-origin"},"-ms-transform-origin":{"_aliasOf":"transform-origin"},"-o-transform-origin":{"_aliasOf":"transform-origin"},"-webkit-transform-style":{"_aliasOf":"transform-style"},"-moz-transform-style":{"_aliasOf":"transform-style"},"-webkit-transition":{"_aliasOf":"transition"},"-moz-transition":{"_aliasOf":"transition"},"-ms-transition":{"_aliasOf":"transition"},"-o-transition":{"_aliasOf":"transition"},"-webkit-transition-delay":{"_aliasOf":"transition-delay"},"-moz-transition-delay":{"_aliasOf":"transition-delay"},"-ms-transition-delay":{"_aliasOf":"transition-delay"},"-o-transition-delay":{"_aliasOf":"transition-delay"},"-webkit-transition-duration":{"_aliasOf":"transition-duration"},"-moz-transition-duration":{"_aliasOf":"transition-duration"},"-ms-transition-duration":{"_aliasOf":"transition-duration"},"-o-transition-duration":{"_aliasOf":"transition-duration"},"-webkit-transition-property":{"_aliasOf":"transition-property"},"-moz-transition-property":{"_aliasOf":"transition-property"},"-ms-transition-property":{"_aliasOf":"transition-property"},"-o-transition-property":{"_aliasOf":"transition-property"},"-webkit-transition-timing-function":{"_aliasOf":"transition-timing-function"},"-moz-transition-timing-function":{"_aliasOf":"transition-timing-function"},"-ms-transition-timing-function":{"_aliasOf":"transition-timing-function"},"-o-transition-timing-function":{"_aliasOf":"transition-timing-function"},"-webkit-user-modify":{"_aliasOf":"user-modify"},"-moz-user-modify":{"_aliasOf":"user-modify"},"-khtml-user-modify":{"_aliasOf":"user-modify"},"-webkit-user-select":{"_aliasOf":"user-select"},"-ms-user-select":{"_aliasOf":"user-select"},"-moz-user-select":{"_aliasOf":"user-select"},"-khtml-user-select":{"_aliasOf":"user-select"},"-ms-word-break":{"_aliasOf":"word-break"},"-webkit-writing-mode":{"_aliasOf":"writing-mode"},"-ms-writing-mode":{"_aliasOf":"writing-mode"}} \ No newline at end of file diff --git a/devtools/shared/compatibility/dataset/moz.build b/devtools/shared/compatibility/dataset/moz.build new file mode 100644 index 0000000000..6fb6046216 --- /dev/null +++ b/devtools/shared/compatibility/dataset/moz.build @@ -0,0 +1,9 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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( + "css-properties.json", +) diff --git a/devtools/shared/compatibility/helpers.js b/devtools/shared/compatibility/helpers.js new file mode 100644 index 0000000000..d69e92a929 --- /dev/null +++ b/devtools/shared/compatibility/helpers.js @@ -0,0 +1,112 @@ +/* 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"; + +// This file might be required from a node script (./bin/update.js), so don't use +// Chrome API here. + +/** + * Return the compatibility table from given compatNode and specified terms. + * For example, if the terms is ["background-color"], + * this function returns compatNode["background-color"].__compat. + * + * @return {Object} compatibility table + * { + * description: {String} Description of this compatibility table. + * mdn_url: {String} Document in the MDN. + * support: { + * $browserName: {String} $browserName is such as firefox, firefox_android and so on. + * [ + * { + * added: {String} + * The version this feature was added. + * removed: {String} Optional. + * The version this feature was removed. Optional. + * prefix: {String} Optional. + * The prefix this feature is needed such as "-moz-". + * alternative_name: {String} Optional. + * The alternative name of this feature such as "-moz-osx-font-smoothing" of "font-smooth". + * notes: {String} Optional. + * A simple note for this support. + * }, + * ... + * ], + * }, + * status: { + * experimental: {Boolean} If true, this feature is experimental. + * standard_track: {Boolean}, If true, this feature is on the standard track. + * deprecated: {Boolean} If true, this feature is deprecated. + * } + * } + */ +function getCompatTable(compatNode, terms) { + let targetNode = getCompatNode(compatNode, terms); + + if (!targetNode) { + return null; + } + + if (!targetNode.__compat) { + for (const field in targetNode) { + // TODO: We don't have a way to know the context for now. + // Thus, use first context node as the compat table. + // e.g. flex_context of align-item + // https://github.com/mdn/browser-compat-data/blob/master/css/properties/align-items.json#L5 + if (field.endsWith("_context")) { + targetNode = targetNode[field]; + break; + } + } + } + + return targetNode.__compat; +} + +/** + * Return a compatibility node which is target for `terms` parameter from `compatNode` + * parameter. For example, when check `background-clip: content-box;`, the `terms` will + * be ["background-clip", "content-box"]. Then, follow the name of terms from the + * compatNode node, return the target node. Although this function actually do more + * complex a bit, if it says simply, returns a node of + * compatNode["background-clip"]["content-box""] . + */ +function getCompatNode(compatNode, terms) { + for (const term of terms) { + compatNode = getChildCompatNode(compatNode, term); + if (!compatNode) { + return null; + } + } + + return compatNode; +} + +function getChildCompatNode(compatNode, term) { + term = term.toLowerCase(); + + let child = null; + for (const field in compatNode) { + if (field.toLowerCase() === term) { + child = compatNode[field]; + break; + } + } + + if (!child) { + return null; + } + + if (child._aliasOf) { + // If the node is an alias, returns the node the alias points. + child = compatNode[child._aliasOf]; + } + + return child; +} + +module.exports = { + getCompatNode, + getCompatTable, +}; diff --git a/devtools/shared/compatibility/moz.build b/devtools/shared/compatibility/moz.build new file mode 100644 index 0000000000..aa37d28738 --- /dev/null +++ b/devtools/shared/compatibility/moz.build @@ -0,0 +1,18 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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 += [ + "dataset", +] + +DevToolsModules( + "compatibility-user-settings.js", + "constants.js", + "helpers.js", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Inspector: Compatibility") diff --git a/devtools/shared/compatibility/package.json b/devtools/shared/compatibility/package.json new file mode 100644 index 0000000000..d4a93d45bf --- /dev/null +++ b/devtools/shared/compatibility/package.json @@ -0,0 +1,13 @@ +{ + "name": "devtools-mdn-compat-data", + "version": "1.0.0", + "description": "Handle MDN compatibility data used by the Compatibility", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "update": "node bin/update" + }, + "author": "", + "devDependencies": { + "@mdn/browser-compat-data": "^" + } +} diff --git a/devtools/shared/constants.js b/devtools/shared/constants.js new file mode 100644 index 0000000000..62381fbb35 --- /dev/null +++ b/devtools/shared/constants.js @@ -0,0 +1,166 @@ +/* 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"; + +/** + * Constants used in various panels, shared between client and the server. + */ + +/* Accessibility Panel ====================================================== */ + +// List of audit types. +const AUDIT_TYPE = { + CONTRAST: "CONTRAST", + KEYBOARD: "KEYBOARD", + TEXT_LABEL: "TEXT_LABEL", +}; + +// Types of issues grouped by audit types. +const ISSUE_TYPE = { + [AUDIT_TYPE.KEYBOARD]: { + // Focusable accessible objects have no semantics. + FOCUSABLE_NO_SEMANTICS: "FOCUSABLE_NO_SEMANTICS", + // Tab index greater than 0 is provided. + FOCUSABLE_POSITIVE_TABINDEX: "FOCUSABLE_POSITIVE_TABINDEX", + // Interactive accesible objects do not have an associated action. + INTERACTIVE_NO_ACTION: "INTERACTIVE_NO_ACTION", + // Interative accessible objcets are not focusable. + INTERACTIVE_NOT_FOCUSABLE: "INTERACTIVE_NOT_FOCUSABLE", + // Accessible objects can only be interacted with a mouse. + MOUSE_INTERACTIVE_ONLY: "MOUSE_INTERACTIVE_ONLY", + // Focusable accessible objects have no focus styling. + NO_FOCUS_VISIBLE: "NO_FOCUS_VISIBLE", + }, + [AUDIT_TYPE.TEXT_LABEL]: { + // name is provided via "alt" attribute. + AREA_NO_NAME_FROM_ALT: "AREA_NO_NAME_FROM_ALT", + // Dialog name is not provided. + DIALOG_NO_NAME: "DIALOG_NO_NAME", + // Document title is not provided. + DOCUMENT_NO_TITLE: "DOCUMENT_NO_TITLE", + // name is not provided. + EMBED_NO_NAME: "EMBED_NO_NAME", + //
name is not provided. + FIGURE_NO_NAME: "FIGURE_NO_NAME", + //
name is not provided. + FORM_FIELDSET_NO_NAME: "FORM_FIELDSET_NO_NAME", + //
name is not provided via element. + FORM_FIELDSET_NO_NAME_FROM_LEGEND: "FORM_FIELDSET_NO_NAME_FROM_LEGEND", + // Form element's name is not provided. + FORM_NO_NAME: "FORM_NO_NAME", + // Form element's name is not visible. + FORM_NO_VISIBLE_NAME: "FORM_NO_VISIBLE_NAME", + // name is not provided via "label" attribute. + FORM_OPTGROUP_NO_NAME_FROM_LABEL: "FORM_OPTGROUP_NO_NAME_FROM_LABEL", + // name is not provided. + FRAME_NO_NAME: "FRAME_NO_NAME", + // has no content. + HEADING_NO_CONTENT: "HEADING_NO_CONTENT", + // name is not provided. + HEADING_NO_NAME: "HEADING_NO_NAME", + // + + diff --git a/devtools/shared/webconsole/test/chrome/test_basics.html b/devtools/shared/webconsole/test/chrome/test_basics.html new file mode 100644 index 0000000000..761a97bc8d --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_basics.html @@ -0,0 +1,61 @@ + + + + + Basic Web Console Actor tests + + + + + +

Basic Web Console Actor tests

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_cached_messages.html b/devtools/shared/webconsole/test/chrome/test_cached_messages.html new file mode 100644 index 0000000000..12d5069c7d --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_cached_messages.html @@ -0,0 +1,217 @@ + + + + + Test for cached messages + + + + + +

Test for cached messages

+ + + + + + diff --git a/devtools/shared/webconsole/test/chrome/test_console_assert.html b/devtools/shared/webconsole/test/chrome/test_console_assert.html new file mode 100644 index 0000000000..f847d5f5d8 --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_console_assert.html @@ -0,0 +1,106 @@ + + + + + Test for console.group styling with %c + + + + + + +

+ +
+  
+ + diff --git a/devtools/shared/webconsole/test/chrome/test_console_group_styling.html b/devtools/shared/webconsole/test/chrome/test_console_group_styling.html new file mode 100644 index 0000000000..23a93f8b8d --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_console_group_styling.html @@ -0,0 +1,121 @@ + + + + + Test for console.group styling with %c + + + + + + +

+ +
+  
+ + diff --git a/devtools/shared/webconsole/test/chrome/test_console_serviceworker.html b/devtools/shared/webconsole/test/chrome/test_console_serviceworker.html new file mode 100644 index 0000000000..33b6ba1457 --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_console_serviceworker.html @@ -0,0 +1,202 @@ + + + + + Test for the Console API and Service Workers + + + + + +

Test for the Console API and Service Workers

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_console_serviceworker_cached.html b/devtools/shared/webconsole/test/chrome/test_console_serviceworker_cached.html new file mode 100644 index 0000000000..9f94f82f93 --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_console_serviceworker_cached.html @@ -0,0 +1,119 @@ + + + + + Test for getCachedMessages and Service Workers + + + + + +

Test for getCachedMessages and Service Workers

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_console_styling.html b/devtools/shared/webconsole/test/chrome/test_console_styling.html new file mode 100644 index 0000000000..841e19076f --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_console_styling.html @@ -0,0 +1,134 @@ + + + + + Test for console.log styling with %c + + + + + +

Test for console.log styling with %c

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_console_timestamp.html b/devtools/shared/webconsole/test/chrome/test_console_timestamp.html new file mode 100644 index 0000000000..a64de0d7b2 --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_console_timestamp.html @@ -0,0 +1,48 @@ + + + + + Test for console.group styling with %c + + + + + + +

+ +
+  
+ + diff --git a/devtools/shared/webconsole/test/chrome/test_console_worker.html b/devtools/shared/webconsole/test/chrome/test_console_worker.html new file mode 100644 index 0000000000..330c083f6b --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_console_worker.html @@ -0,0 +1,73 @@ + + + + + Test for the Console API and Workers + + + + + +

Test for the Console API and Workers

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_consoleapi.html b/devtools/shared/webconsole/test/chrome/test_consoleapi.html new file mode 100644 index 0000000000..b5d8edf23e --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_consoleapi.html @@ -0,0 +1,225 @@ + + + + + Test for the Console API + + + + + +

Test for the Console API

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_consoleapi_innerID.html b/devtools/shared/webconsole/test/chrome/test_consoleapi_innerID.html new file mode 100644 index 0000000000..98fc783a84 --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_consoleapi_innerID.html @@ -0,0 +1,157 @@ + + + + + Test for the innerID property of the Console API + + + + + +

Test for the Console API

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_file_uri.html b/devtools/shared/webconsole/test/chrome/test_file_uri.html new file mode 100644 index 0000000000..76c3b8193e --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_file_uri.html @@ -0,0 +1,110 @@ + + + + + Test for file activity tracking + + + + + +

Test for file activity tracking

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_jsterm_autocomplete.html b/devtools/shared/webconsole/test/chrome/test_jsterm_autocomplete.html new file mode 100644 index 0000000000..bbc9aeb6fc --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_jsterm_autocomplete.html @@ -0,0 +1,635 @@ + + + + + Test for JavaScript terminal functionality + + + + + +

Test for JavaScript terminal autocomplete functionality

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_network_get.html b/devtools/shared/webconsole/test/chrome/test_network_get.html new file mode 100644 index 0000000000..71bb6c388c --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_network_get.html @@ -0,0 +1,132 @@ + + + + + Test for the network actor (GET request) + + + + + +

Test for the network actor (GET request)

+ + + + + + diff --git a/devtools/shared/webconsole/test/chrome/test_network_post.html b/devtools/shared/webconsole/test/chrome/test_network_post.html new file mode 100644 index 0000000000..237494c323 --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_network_post.html @@ -0,0 +1,143 @@ + + + + + Test for the network actor (POST request) + + + + + +

Test for the network actor (POST request)

+ + + + + + diff --git a/devtools/shared/webconsole/test/chrome/test_network_security-hsts.html b/devtools/shared/webconsole/test/chrome/test_network_security-hsts.html new file mode 100644 index 0000000000..6139aa4e05 --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_network_security-hsts.html @@ -0,0 +1,89 @@ + + + + + Test for the network actor (HSTS detection) + + + + + +

Test for the network actor (HSTS detection)

+ + + + + + diff --git a/devtools/shared/webconsole/test/chrome/test_nsiconsolemessage.html b/devtools/shared/webconsole/test/chrome/test_nsiconsolemessage.html new file mode 100644 index 0000000000..4e460225df --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_nsiconsolemessage.html @@ -0,0 +1,74 @@ + + + + + Test for nsIConsoleMessages + + + + + +

Make sure that nsIConsoleMessages are logged. See bug 859756.

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_object_actor.html b/devtools/shared/webconsole/test/chrome/test_object_actor.html new file mode 100644 index 0000000000..f42035e2ce --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_object_actor.html @@ -0,0 +1,158 @@ + + + + + Test for the object actor + + + + + +

Test for the object actor

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_object_actor_native_getters.html b/devtools/shared/webconsole/test/chrome/test_object_actor_native_getters.html new file mode 100644 index 0000000000..6ac292fb9a --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_object_actor_native_getters.html @@ -0,0 +1,75 @@ + + + + + Test for the native getters in object actors + + + + + +

Test for the native getters in object actors

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_object_actor_native_getters_lenient_this.html b/devtools/shared/webconsole/test/chrome/test_object_actor_native_getters_lenient_this.html new file mode 100644 index 0000000000..473dfb3b03 --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_object_actor_native_getters_lenient_this.html @@ -0,0 +1,54 @@ + + + + + Test that WebIDL attributes with the LenientThis extended attribute + do not appear in the wrong objects + + + + + +

Test for the native getters in object actors

+ + + + diff --git a/devtools/shared/webconsole/test/chrome/test_page_errors.html b/devtools/shared/webconsole/test/chrome/test_page_errors.html new file mode 100644 index 0000000000..0976eed92d --- /dev/null +++ b/devtools/shared/webconsole/test/chrome/test_page_errors.html @@ -0,0 +1,224 @@ + + + + + Test for page errors + + + + + +

Test for page errors

+ + + + diff --git a/devtools/shared/webconsole/test/xpcshell/.eslintrc.js b/devtools/shared/webconsole/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..8611c174f5 --- /dev/null +++ b/devtools/shared/webconsole/test/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the common devtools xpcshell eslintrc config. + extends: "../../../../.eslintrc.xpcshell.js", +}; diff --git a/devtools/shared/webconsole/test/xpcshell/head.js b/devtools/shared/webconsole/test/xpcshell/head.js new file mode 100644 index 0000000000..e65552771e --- /dev/null +++ b/devtools/shared/webconsole/test/xpcshell/head.js @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* exported require */ + +"use strict"; + +var { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); diff --git a/devtools/shared/webconsole/test/xpcshell/test_analyze_input_string.js b/devtools/shared/webconsole/test/xpcshell/test_analyze_input_string.js new file mode 100644 index 0000000000..3df015056f --- /dev/null +++ b/devtools/shared/webconsole/test/xpcshell/test_analyze_input_string.js @@ -0,0 +1,225 @@ +// Any copyright is dedicated to the Public Domain. +// http://creativecommons.org/publicdomain/zero/1.0/ + +"use strict"; +const { + analyzeInputString, +} = require("resource://devtools/shared/webconsole/analyze-input-string.js"); + +add_task(() => { + const tests = [ + { + desc: "simple property access", + input: `var a = {b: 1};a.b`, + expected: { + isElementAccess: false, + isPropertyAccess: true, + expressionBeforePropertyAccess: `var a = {b: 1};a`, + lastStatement: "a.b", + mainExpression: `a`, + matchProp: `b`, + }, + }, + { + desc: "deep property access", + input: `a.b.c`, + expected: { + isElementAccess: false, + isPropertyAccess: true, + expressionBeforePropertyAccess: `a.b`, + lastStatement: "a.b.c", + mainExpression: `a.b`, + matchProp: `c`, + }, + }, + { + desc: "element access", + input: `a["b`, + expected: { + isElementAccess: true, + isPropertyAccess: true, + expressionBeforePropertyAccess: `a`, + lastStatement: `a["b`, + mainExpression: `a`, + matchProp: `"b`, + }, + }, + { + desc: "element access without quotes", + input: `a[b`, + expected: { + isElementAccess: true, + isPropertyAccess: true, + expressionBeforePropertyAccess: `a`, + lastStatement: `a[b`, + mainExpression: `a`, + matchProp: `b`, + }, + }, + { + desc: "simple optional chaining access", + input: `a?.b`, + expected: { + isElementAccess: false, + isPropertyAccess: true, + expressionBeforePropertyAccess: `a`, + lastStatement: `a?.b`, + mainExpression: `a`, + matchProp: `b`, + }, + }, + { + desc: "deep optional chaining access", + input: `a?.b?.c`, + expected: { + isElementAccess: false, + isPropertyAccess: true, + expressionBeforePropertyAccess: `a?.b`, + lastStatement: `a?.b?.c`, + mainExpression: `a?.b`, + matchProp: `c`, + }, + }, + { + desc: "optional chaining element access", + input: `a?.["b`, + expected: { + isElementAccess: true, + isPropertyAccess: true, + expressionBeforePropertyAccess: `a`, + lastStatement: `a?.["b`, + mainExpression: `a`, + matchProp: `"b`, + }, + }, + { + desc: "optional chaining element access without quotes", + input: `a?.[b`, + expected: { + isElementAccess: true, + isPropertyAccess: true, + expressionBeforePropertyAccess: `a`, + lastStatement: `a?.[b`, + mainExpression: `a`, + matchProp: `b`, + }, + }, + { + desc: "deep optional chaining element access with quotes", + input: `var a = {b: 1, c: ["."]}; a?.["b"]?.c?.["d[.`, + expected: { + isElementAccess: true, + isPropertyAccess: true, + expressionBeforePropertyAccess: `var a = {b: 1, c: ["."]}; a?.["b"]?.c`, + lastStatement: `a?.["b"]?.c?.["d[.`, + mainExpression: `a?.["b"]?.c`, + matchProp: `"d[.`, + }, + }, + { + desc: "literal arrays with newline", + input: `[1,2,3,\n4\n].`, + expected: { + isElementAccess: false, + isPropertyAccess: true, + expressionBeforePropertyAccess: `[1,2,3,\n4\n]`, + lastStatement: `[1,2,3,4].`, + mainExpression: `[1,2,3,4]`, + matchProp: ``, + }, + }, + { + desc: "number literal with newline", + input: `1\n.`, + expected: { + isElementAccess: false, + isPropertyAccess: true, + expressionBeforePropertyAccess: `1\n`, + lastStatement: `1\n.`, + mainExpression: `1`, + matchProp: ``, + }, + }, + { + desc: "string literal", + input: `"abc".`, + expected: { + isElementAccess: false, + isPropertyAccess: true, + expressionBeforePropertyAccess: `"abc"`, + lastStatement: `"abc".`, + mainExpression: `"abc"`, + matchProp: ``, + }, + }, + { + desc: "string literal containing backslash", + input: `"\\n".`, + expected: { + isElementAccess: false, + isPropertyAccess: true, + expressionBeforePropertyAccess: `"\\n"`, + lastStatement: `"\\n".`, + mainExpression: `"\\n"`, + matchProp: ``, + }, + }, + { + desc: "single quote string literal containing backslash", + input: `'\\r'.`, + expected: { + isElementAccess: false, + isPropertyAccess: true, + expressionBeforePropertyAccess: `'\\r'`, + lastStatement: `'\\r'.`, + mainExpression: `'\\r'`, + matchProp: ``, + }, + }, + { + desc: "template string literal containing backslash", + input: "`\\\\`.", + expected: { + isElementAccess: false, + isPropertyAccess: true, + expressionBeforePropertyAccess: "`\\\\`", + lastStatement: "`\\\\`.", + mainExpression: "`\\\\`", + matchProp: ``, + }, + }, + { + desc: "unterminated double quote string literal", + input: `"\n`, + expected: { + err: "unterminated string literal", + }, + }, + { + desc: "unterminated single quote string literal", + input: `'\n`, + expected: { + err: "unterminated string literal", + }, + }, + { + desc: "optional chaining operator with spaces", + input: `test ?. ["propA"] ?. [0] ?. ["propB"] ?. ['to`, + expected: { + isElementAccess: true, + isPropertyAccess: true, + expressionBeforePropertyAccess: `test ?. ["propA"] ?. [0] ?. ["propB"] `, + lastStatement: `test ?. ["propA"] ?. [0] ?. ["propB"] ?. ['to`, + mainExpression: `test ?. ["propA"] ?. [0] ?. ["propB"]`, + matchProp: `'to`, + }, + }, + ]; + + for (const { input, desc, expected } of tests) { + const result = analyzeInputString(input); + for (const [key, value] of Object.entries(expected)) { + Assert.equal(value, result[key], `${desc} | ${key} has expected value`); + } + } +}); diff --git a/devtools/shared/webconsole/test/xpcshell/test_js_property_provider.js b/devtools/shared/webconsole/test/xpcshell/test_js_property_provider.js new file mode 100644 index 0000000000..a6f2daee4b --- /dev/null +++ b/devtools/shared/webconsole/test/xpcshell/test_js_property_provider.js @@ -0,0 +1,746 @@ +// Any copyright is dedicated to the Public Domain. +// http://creativecommons.org/publicdomain/zero/1.0/ + +"use strict"; +const { + FallibleJSPropertyProvider: JSPropertyProvider, +} = require("resource://devtools/shared/webconsole/js-property-provider.js"); + +const { addDebuggerToGlobal } = ChromeUtils.importESModule( + "resource://gre/modules/jsdebugger.sys.mjs" +); +addDebuggerToGlobal(globalThis); + +function run_test() { + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads"); + }); + + const testArray = `var testArray = [ + {propA: "A"}, + { + propB: "B", + propC: [ + "D" + ] + }, + [ + {propE: "E"} + ] + ]`; + + const testObject = 'var testObject = {"propA": [{"propB": "B"}]}'; + const testHyphenated = 'var testHyphenated = {"prop-A": "res-A"}'; + const testLet = "let foobar = {a: ''}; const blargh = {a: 1};"; + + const testGenerators = ` + // Test with generator using a named function. + function* genFunc() { + for (let i = 0; i < 10; i++) { + yield i; + } + } + let gen1 = genFunc(); + gen1.next(); + + // Test with generator using an anonymous function. + let gen2 = (function* () { + for (let i = 0; i < 10; i++) { + yield i; + } + })();`; + + const testGetters = ` + var testGetters = { + get x() { + return Object.create(null, Object.getOwnPropertyDescriptors({ + hello: "", + world: "", + })); + }, + get y() { + return Object.create(null, Object.getOwnPropertyDescriptors({ + get y() { + return "plop"; + }, + })); + } + }; + `; + + const testProxies = ` + var testSelfPrototypeProxy = new Proxy({ + hello: 1 + }, { + getPrototypeOf: () => testProxy + }); + var testArrayPrototypeProxy = new Proxy({ + world: 2 + }, { + getPrototypeOf: () => Array.prototype + }) + `; + + const sandbox = Cu.Sandbox("http://example.com"); + const dbg = new Debugger(); + const dbgObject = dbg.addDebuggee(sandbox); + const dbgEnv = dbgObject.asEnvironment(); + Cu.evalInSandbox( + ` + const hello = Object.create(null, Object.getOwnPropertyDescriptors({world: 1})); + String.prototype.hello = hello; + Number.prototype.hello = hello; + Array.prototype.hello = hello; + `, + sandbox + ); + Cu.evalInSandbox(testArray, sandbox); + Cu.evalInSandbox(testObject, sandbox); + Cu.evalInSandbox(testHyphenated, sandbox); + Cu.evalInSandbox(testLet, sandbox); + Cu.evalInSandbox(testGenerators, sandbox); + Cu.evalInSandbox(testGetters, sandbox); + Cu.evalInSandbox(testProxies, sandbox); + + info("Running tests with dbgObject"); + runChecks(dbgObject, null, sandbox); + + info("Running tests with dbgEnv"); + runChecks(null, dbgEnv, sandbox); +} + +function runChecks(dbgObject, environment, sandbox) { + const propertyProvider = (inputValue, options) => + JSPropertyProvider({ + dbgObject, + environment, + inputValue, + ...options, + }); + + info("Test that suggestions are given for 'this'"); + let results = propertyProvider("t"); + test_has_result(results, "this"); + + if (dbgObject != null) { + info("Test that suggestions are given for 'this.'"); + results = propertyProvider("this."); + test_has_result(results, "testObject"); + + info("Test that suggestions are given for '(this).'"); + results = propertyProvider("(this)."); + test_has_result(results, "testObject"); + + info("Test that suggestions are given for deep 'this' properties access"); + results = propertyProvider("(this).testObject.propA."); + test_has_result(results, "shift"); + + results = propertyProvider("(this).testObject.propA["); + test_has_result(results, `"shift"`); + + results = propertyProvider("(this)['testObject']['propA']["); + test_has_result(results, `"shift"`); + + results = propertyProvider("(this).testObject['propA']."); + test_has_result(results, "shift"); + + info("Test that no suggestions are given for 'this.this'"); + results = propertyProvider("this.this"); + test_has_no_results(results); + } + + info("Test that suggestions are given for 'globalThis'"); + results = propertyProvider("g"); + test_has_result(results, "globalThis"); + + info("Test that suggestions are given for 'globalThis.'"); + results = propertyProvider("globalThis."); + test_has_result(results, "testObject"); + + info("Test that suggestions are given for '(globalThis).'"); + results = propertyProvider("(globalThis)."); + test_has_result(results, "testObject"); + + info( + "Test that suggestions are given for deep 'globalThis' properties access" + ); + results = propertyProvider("(globalThis).testObject.propA."); + test_has_result(results, "shift"); + + results = propertyProvider("(globalThis).testObject.propA["); + test_has_result(results, `"shift"`); + + results = propertyProvider("(globalThis)['testObject']['propA']["); + test_has_result(results, `"shift"`); + + results = propertyProvider("(globalThis).testObject['propA']."); + test_has_result(results, "shift"); + + info("Testing lexical scope issues (Bug 1207868)"); + results = propertyProvider("foobar"); + test_has_result(results, "foobar"); + + results = propertyProvider("foobar."); + test_has_result(results, "a"); + + results = propertyProvider("blargh"); + test_has_result(results, "blargh"); + + results = propertyProvider("blargh."); + test_has_result(results, "a"); + + info("Test that suggestions are given for 'foo[n]' where n is an integer."); + results = propertyProvider("testArray[0]."); + test_has_result(results, "propA"); + + info("Test that suggestions are given for multidimensional arrays."); + results = propertyProvider("testArray[2][0]."); + test_has_result(results, "propE"); + + info("Test that suggestions are given for nested arrays."); + results = propertyProvider("testArray[1].propC[0]."); + test_has_result(results, "indexOf"); + + info("Test that suggestions are given for literal arrays."); + results = propertyProvider("[1,2,3]."); + test_has_result(results, "indexOf"); + + results = propertyProvider("[1,2,3].h"); + test_has_result(results, "hello"); + + results = propertyProvider("[1,2,3].hello.w"); + test_has_result(results, "world"); + + info("Test that suggestions are given for literal arrays with newlines."); + results = propertyProvider("[1,2,3,\n4\n]."); + test_has_result(results, "indexOf"); + + info("Test that suggestions are given for literal strings."); + results = propertyProvider("'foo'."); + test_has_result(results, "charAt"); + results = propertyProvider('"foo".'); + test_has_result(results, "charAt"); + results = propertyProvider("`foo`."); + test_has_result(results, "charAt"); + results = propertyProvider("`foo doc`."); + test_has_result(results, "charAt"); + results = propertyProvider('`foo " doc`.'); + test_has_result(results, "charAt"); + results = propertyProvider("`foo ' doc`."); + test_has_result(results, "charAt"); + results = propertyProvider("'[1,2,3]'."); + test_has_result(results, "charAt"); + results = propertyProvider("'foo'.h"); + test_has_result(results, "hello"); + results = propertyProvider("'foo'.hello.w"); + test_has_result(results, "world"); + results = propertyProvider(`"\\n".`); + test_has_result(results, "charAt"); + results = propertyProvider(`'\\r'.`); + test_has_result(results, "charAt"); + results = propertyProvider("`\\\\`."); + test_has_result(results, "charAt"); + + info("Test that suggestions are not given for syntax errors."); + results = propertyProvider("'foo\""); + Assert.equal(null, results); + results = propertyProvider("'foo d"); + Assert.equal(null, results); + results = propertyProvider(`"foo d`); + Assert.equal(null, results); + results = propertyProvider("`foo d"); + Assert.equal(null, results); + results = propertyProvider("[1,',2]"); + Assert.equal(null, results); + results = propertyProvider("'[1,2]."); + Assert.equal(null, results); + results = propertyProvider("'foo'.."); + Assert.equal(null, results); + + info("Test that suggestions are not given without a dot."); + results = propertyProvider("'foo'"); + test_has_no_results(results); + results = propertyProvider("`foo`"); + test_has_no_results(results); + results = propertyProvider("[1,2,3]"); + test_has_no_results(results); + results = propertyProvider("[1,2,3].\n'foo'"); + test_has_no_results(results); + + info("Test that suggestions are not given for index that's out of bounds."); + results = propertyProvider("testArray[10]."); + Assert.equal(null, results); + + info("Test that invalid element access syntax does not return anything"); + results = propertyProvider("testArray[][1]."); + Assert.equal(null, results); + + info("Test that deep element access works."); + results = propertyProvider("testObject['propA'][0]."); + test_has_result(results, "propB"); + + results = propertyProvider("testArray[1]['propC']."); + test_has_result(results, "shift"); + + results = propertyProvider("testArray[1].propC[0]["); + test_has_result(results, `"trim"`); + + results = propertyProvider("testArray[1].propC[0]."); + test_has_result(results, "trim"); + + info( + "Test that suggestions are displayed when variable is wrapped in parens" + ); + results = propertyProvider("(testObject)['propA'][0]."); + test_has_result(results, "propB"); + + results = propertyProvider("(testArray)[1]['propC']."); + test_has_result(results, "shift"); + + results = propertyProvider("(testArray)[1].propC[0]["); + test_has_result(results, `"trim"`); + + results = propertyProvider("(testArray)[1].propC[0]."); + test_has_result(results, "trim"); + + info("Test that suggestions are given if there is an hyphen in the chain."); + results = propertyProvider("testHyphenated['prop-A']."); + test_has_result(results, "trim"); + + info("Test that we have suggestions for generators."); + const gen1Result = Cu.evalInSandbox("gen1.next().value", sandbox); + results = propertyProvider("gen1."); + test_has_result(results, "next"); + info("Test that the generator next() was not executed"); + const gen1NextResult = Cu.evalInSandbox("gen1.next().value", sandbox); + Assert.equal(gen1Result + 1, gen1NextResult); + + info("Test with an anonymous generator."); + const gen2Result = Cu.evalInSandbox("gen2.next().value", sandbox); + results = propertyProvider("gen2."); + test_has_result(results, "next"); + const gen2NextResult = Cu.evalInSandbox("gen2.next().value", sandbox); + Assert.equal(gen2Result + 1, gen2NextResult); + + info( + "Test that getters are not executed if authorizedEvaluations is undefined" + ); + results = propertyProvider("testGetters.x."); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "x"], + }); + + results = propertyProvider("testGetters.x["); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "x"], + }); + + results = propertyProvider("testGetters.x.hell"); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "x"], + }); + + results = propertyProvider("testGetters.x['hell"); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "x"], + }); + + info( + "Test that getters are not executed if authorizedEvaluations does not match" + ); + results = propertyProvider("testGetters.x.", { authorizedEvaluations: [] }); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "x"], + }); + + results = propertyProvider("testGetters.x.", { + authorizedEvaluations: [["testGetters"]], + }); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "x"], + }); + + results = propertyProvider("testGetters.x.", { + authorizedEvaluations: [["testGtrs", "x"]], + }); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "x"], + }); + + results = propertyProvider("testGetters.x.", { + authorizedEvaluations: [["x"]], + }); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "x"], + }); + + info("Test that deep getter property access returns intermediate getters"); + results = propertyProvider("testGetters.y.y."); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "y"], + }); + + results = propertyProvider("testGetters['y'].y."); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "y"], + }); + + results = propertyProvider("testGetters['y']['y']."); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "y"], + }); + + results = propertyProvider("testGetters.y['y']."); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "y"], + }); + + info("Test that deep getter property access invoke intermediate getters"); + results = propertyProvider("testGetters.y.y.", { + authorizedEvaluations: [["testGetters", "y"]], + }); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "y", "y"], + }); + + results = propertyProvider("testGetters['y'].y.", { + authorizedEvaluations: [["testGetters", "y"]], + }); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "y", "y"], + }); + + results = propertyProvider("testGetters['y']['y'].", { + authorizedEvaluations: [["testGetters", "y"]], + }); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "y", "y"], + }); + + results = propertyProvider("testGetters.y['y'].", { + authorizedEvaluations: [["testGetters", "y"]], + }); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "y", "y"], + }); + + info( + "Test that getters are executed if matching an authorizedEvaluation element" + ); + results = propertyProvider("testGetters.x.", { + authorizedEvaluations: [["testGetters", "x"]], + }); + test_has_exact_results(results, ["hello", "world"]); + Assert.ok(Object.keys(results).includes("isUnsafeGetter") === false); + Assert.ok(Object.keys(results).includes("getterPath") === false); + + results = propertyProvider("testGetters.x.", { + authorizedEvaluations: [["testGetters", "x"], ["y"]], + }); + test_has_exact_results(results, ["hello", "world"]); + Assert.ok(Object.keys(results).includes("isUnsafeGetter") === false); + Assert.ok(Object.keys(results).includes("getterPath") === false); + + info("Test that executing getters filters with provided string"); + results = propertyProvider("testGetters.x.hell", { + authorizedEvaluations: [["testGetters", "x"]], + }); + test_has_exact_results(results, ["hello"]); + + results = propertyProvider("testGetters.x['hell", { + authorizedEvaluations: [["testGetters", "x"]], + }); + test_has_exact_results(results, ["'hello'"]); + + info( + "Test children getters are not executed if not included in authorizedEvaluation" + ); + results = propertyProvider("testGetters.y.y.", { + authorizedEvaluations: [["testGetters", "y", "y"]], + }); + Assert.deepEqual(results, { + isUnsafeGetter: true, + getterPath: ["testGetters", "y"], + }); + + info( + "Test children getters are executed if matching an authorizedEvaluation element" + ); + results = propertyProvider("testGetters.y.y.", { + authorizedEvaluations: [ + ["testGetters", "y"], + ["testGetters", "y", "y"], + ], + }); + test_has_result(results, "trim"); + + info("Test with number literals"); + results = propertyProvider("1."); + Assert.ok(results === null, "Does not complete on possible floating number"); + + results = propertyProvider("(1).."); + Assert.ok(results === null, "Does not complete on invalid syntax"); + + results = propertyProvider("(1.1.)."); + Assert.ok(results === null, "Does not complete on invalid syntax"); + + results = propertyProvider("1.."); + test_has_result(results, "toFixed"); + + results = propertyProvider("1 ."); + test_has_result(results, "toFixed"); + + results = propertyProvider("1\n."); + test_has_result(results, "toFixed"); + + results = propertyProvider(".1."); + test_has_result(results, "toFixed"); + + results = propertyProvider("1["); + test_has_result(results, `"toFixed"`); + + results = propertyProvider("1[toFixed"); + test_has_exact_results(results, [`"toFixed"`]); + + results = propertyProvider("1['toFixed"); + test_has_exact_results(results, ["'toFixed'"]); + + results = propertyProvider("1.1["); + test_has_result(results, `"toFixed"`); + + results = propertyProvider("(1)."); + test_has_result(results, "toFixed"); + + results = propertyProvider("(.1)."); + test_has_result(results, "toFixed"); + + results = propertyProvider("(1.1)."); + test_has_result(results, "toFixed"); + + results = propertyProvider("(1).toFixed"); + test_has_exact_results(results, ["toFixed"]); + + results = propertyProvider("(1)["); + test_has_result(results, `"toFixed"`); + + results = propertyProvider("(1.1)["); + test_has_result(results, `"toFixed"`); + + results = propertyProvider("(1)[toFixed"); + test_has_exact_results(results, [`"toFixed"`]); + + results = propertyProvider("(1)['toFixed"); + test_has_exact_results(results, ["'toFixed'"]); + + results = propertyProvider("(1).h"); + test_has_result(results, "hello"); + + results = propertyProvider("(1).hello.w"); + test_has_result(results, "world"); + + info("Test access on dot-notation invalid property name"); + results = propertyProvider("testHyphenated.prop"); + Assert.ok( + !results.matches.has("prop-A"), + "Does not return invalid property name on dot access" + ); + + results = propertyProvider("testHyphenated['prop"); + test_has_result(results, `'prop-A'`); + + results = propertyProvider(`//t`); + Assert.ok(results === null, "Does not complete in inline comment"); + + results = propertyProvider(`// t`); + Assert.ok( + results === null, + "Does not complete in inline comment after space" + ); + + results = propertyProvider(`//I'm a comment\nt`); + test_has_result(results, "testObject"); + + results = propertyProvider(`1/t`); + test_has_result(results, "testObject"); + + results = propertyProvider(`/* t`); + Assert.ok(results === null, "Does not complete in multiline comment"); + + results = propertyProvider(`/*I'm\nt`); + Assert.ok( + results === null, + "Does not complete in multiline comment after line break" + ); + + results = propertyProvider(`/*I'm a comment\n \t * /t`); + Assert.ok( + results === null, + "Does not complete in multiline comment after line break and invalid comment end" + ); + + results = propertyProvider(`/*I'm a comment\n \t */t`); + test_has_result(results, "testObject"); + + results = propertyProvider(`/*I'm a comment\n \t */\n\nt`); + test_has_result(results, "testObject"); + + info("Test local expression variables"); + results = propertyProvider("b", { expressionVars: ["a", "b", "c"] }); + test_has_result(results, "b"); + Assert.equal(results.matches.has("a"), false); + Assert.equal(results.matches.has("c"), false); + + info( + "Test that local expression variables are not included when accessing an object properties" + ); + results = propertyProvider("testObject.prop", { + expressionVars: ["propLocal"], + }); + Assert.equal(results.matches.has("propLocal"), false); + test_has_result(results, "propA"); + + results = propertyProvider("testObject['prop", { + expressionVars: ["propLocal"], + }); + test_has_result(results, "'propA'"); + Assert.equal(results.matches.has("propLocal"), false); + + info("Test that expression with optional chaining operator are completed"); + results = propertyProvider("testObject?.prop"); + test_has_result(results, "propA"); + + results = propertyProvider("testObject?.propA[0]?.propB?.to"); + test_has_result(results, "toString"); + + results = propertyProvider("testObject?.propA?.[0]?.propB?.to"); + test_has_result(results, "toString"); + + results = propertyProvider( + "testObject ?. propA[0] ?. propB ?. to" + ); + test_has_result(results, "toString"); + + results = propertyProvider("testObject?.[prop"); + test_has_result(results, '"propA"'); + + results = propertyProvider(`testObject?.["prop`); + test_has_result(results, '"propA"'); + + results = propertyProvider(`testObject?.['prop`); + test_has_result(results, `'propA'`); + + results = propertyProvider(`testObject?.["propA"]?.[0]?.["propB"]?.["to`); + test_has_result(results, `"toString"`); + + results = propertyProvider( + `testObject ?. ["propA"] ?. [0] ?. ["propB"] ?. ['to` + ); + test_has_result(results, "'toString'"); + + results = propertyProvider("[1,2,3]?."); + test_has_result(results, "indexOf"); + + results = propertyProvider("'foo'?."); + test_has_result(results, "charAt"); + + results = propertyProvider("1?."); + test_has_result(results, "toFixed"); + + // check this doesn't throw since `propC` is not defined. + results = propertyProvider("testObject?.propC?.this?.does?.not?.exist?.d"); + + // check that ternary operator isn't mistaken for optional chaining + results = propertyProvider(`true?.3.to`); + test_has_result(results, `toExponential`); + + results = propertyProvider(`true?.3?.to`); + test_has_result(results, `toExponential`); + + // Test more ternary + results = propertyProvider(`true?t`); + test_has_result(results, `testObject`); + + results = propertyProvider(`true??t`); + test_has_result(results, `testObject`); + + results = propertyProvider(`true?/* comment */t`); + test_has_result(results, `testObject`); + + results = propertyProvider(`true? 0); + Assert.ok( + results.matches.has(requiredSuggestion), + `<${requiredSuggestion}> found in ${[...results.matches.values()].join( + " - " + )}` + ); +} + +/** + * A helper that ensures results are the expected ones. + * @param Object results + * The results returned by JSPropertyProvider. + * @param Array expectedMatches + * An array of the properties that should be returned by JsPropertyProvider. + */ +function test_has_exact_results(results, expectedMatches) { + Assert.deepEqual([...results.matches], expectedMatches); +} diff --git a/devtools/shared/webconsole/test/xpcshell/xpcshell.ini b/devtools/shared/webconsole/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..a0d7e75ad1 --- /dev/null +++ b/devtools/shared/webconsole/test/xpcshell/xpcshell.ini @@ -0,0 +1,9 @@ +[DEFAULT] +tags = devtools +head = head.js +firefox-appdir = browser +skip-if = toolkit == 'android' +support-files = + +[test_analyze_input_string.js] +[test_js_property_provider.js] diff --git a/devtools/shared/webextension-fallback.html b/devtools/shared/webextension-fallback.html new file mode 100644 index 0000000000..1ac7005851 --- /dev/null +++ b/devtools/shared/webextension-fallback.html @@ -0,0 +1,6 @@ + + + +

Your addon does not have any document opened yet.

diff --git a/devtools/shared/worker/helper.js b/devtools/shared/worker/helper.js new file mode 100644 index 0000000000..20ccd3de5e --- /dev/null +++ b/devtools/shared/worker/helper.js @@ -0,0 +1,138 @@ +/* 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/. */ + +/* eslint-env amd */ + +"use strict"; + +/* global workerHelper */ +/* exported workerHelper */ +(function (root, factory) { + if (typeof define === "function" && define.amd) { + define(factory); + } else if (typeof exports === "object") { + module.exports = factory(); + } else { + root.workerHelper = factory(); + } +})(this, function () { + /** + * This file is to only be included by ChromeWorkers. This exposes + * a `createTask` function to workers to register tasks for communication + * back to `devtools/shared/worker`. + * + * Tasks can be send their responses via a return value, either a primitive + * or a promise. + * + * createTask(self, "average", function (data) { + * return data.reduce((sum, val) => sum + val, 0) / data.length; + * }); + * + * createTask(self, "average", function (data) { + * return new Promise((resolve, reject) => { + * resolve(data.reduce((sum, val) => sum + val, 0) / data.length); + * }); + * }); + * + * + * Errors: + * + * Returning an Error value, or if the returned promise is rejected, this + * propagates to the DevToolsWorker as a rejected promise. If an error is + * thrown in a synchronous function, that error is also propagated. + */ + + /** + * Takes a worker's `self` object, a task name, and a function to + * be called when that task is called. The task is called with the + * passed in data as the first argument + * + * @param {object} self + * @param {string} name + * @param {function} fn + */ + function createTask(self, name, fn) { + // Store a hash of task name to function on the Worker + if (!self._tasks) { + self._tasks = {}; + } + + // Create the onmessage handler if not yet created. + if (!self.onmessage) { + self.onmessage = createHandler(self); + } + + // Store the task on the worker. + self._tasks[name] = fn; + } + + /** + * Creates the `self.onmessage` handler for a Worker. + * + * @param {object} self + * @return {function} + */ + function createHandler(self) { + return function (e) { + const { id, task, data } = e.data; + const taskFn = self._tasks[task]; + + if (!taskFn) { + self.postMessage({ id, error: `Task "${task}" not found in worker.` }); + return; + } + + try { + handleResponse(taskFn(data)); + } catch (ex) { + handleError(ex); + } + + function handleResponse(response) { + // If a promise + if (response && typeof response.then === "function") { + response.then( + val => self.postMessage({ id, response: val }), + handleError + ); + } else if (response instanceof Error) { + // If an error object + handleError(response); + } else { + // If anything else + self.postMessage({ id, response }); + } + } + + function handleError(error = "Error") { + try { + // First, try and structured clone the error across directly. + self.postMessage({ id, error }); + } catch (x) { + // We could not clone whatever error value was given. Do our best to + // stringify it. + let errorString = `Error while performing task "${task}": `; + + try { + errorString += error.toString(); + } catch (ex) { + errorString += ""; + } + + if ("stack" in error) { + try { + errorString += "\n" + error.stack; + } catch (err) { + // Do nothing + } + } + + self.postMessage({ id, error: errorString }); + } + } + }; + } + + return { createTask }; +}); diff --git a/devtools/shared/worker/moz.build b/devtools/shared/worker/moz.build new file mode 100644 index 0000000000..432b740a85 --- /dev/null +++ b/devtools/shared/worker/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +if CONFIG["MOZ_BUILD_APP"] != "mobile/android": + BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"] + +DevToolsModules( + "helper.js", + "worker.js", +) diff --git a/devtools/shared/worker/tests/browser/browser.ini b/devtools/shared/worker/tests/browser/browser.ini new file mode 100644 index 0000000000..a64916dfff --- /dev/null +++ b/devtools/shared/worker/tests/browser/browser.ini @@ -0,0 +1,9 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + ../../../../server/tests/browser/head.js + +[browser_worker-01.js] +[browser_worker-02.js] +[browser_worker-03.js] diff --git a/devtools/shared/worker/tests/browser/browser_worker-01.js b/devtools/shared/worker/tests/browser/browser_worker-01.js new file mode 100644 index 0000000000..a8dafcf4cb --- /dev/null +++ b/devtools/shared/worker/tests/browser/browser_worker-01.js @@ -0,0 +1,110 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the devtools/shared/worker communicates properly +// as both CommonJS module and as a JSM. + +const BUFFER_SIZE = 8; + +registerCleanupFunction(function () { + Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads"); +}); + +add_task(async function () { + // Test both CJS and JSM versions + + await testWorker("JSM", () => + ChromeUtils.import("resource://devtools/shared/worker/worker.js") + ); + await testWorker("CommonJS", () => + require("resource://devtools/shared/worker/worker.js") + ); + await testTransfer(); +}); + +async function testWorker(context, workerFactory) { + // Needed for blob:null + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + const { DevToolsWorker, workerify } = workerFactory(); + + const blob = new Blob( + [ + ` +importScripts("resource://gre/modules/workers/require.js"); +const { createTask } = require("resource://devtools/shared/worker/helper.js"); + +createTask(self, "groupByField", function({ + items, + groupField +}) { + const groups = {}; + for (const item of items) { + if (!groups[item[groupField]]) { + groups[item[groupField]] = []; + } + groups[item[groupField]].push(item); + } + return { groups }; +}); + `, + ], + { type: "application/javascript" } + ); + + const WORKER_URL = URL.createObjectURL(blob); + const worker = new DevToolsWorker(WORKER_URL); + + const results = await worker.performTask("groupByField", { + items: [ + { name: "Paris", country: "France" }, + { name: "Lagos", country: "Nigeria" }, + { name: "Lyon", country: "France" }, + ], + groupField: "country", + }); + + is( + Object.keys(results.groups).join(","), + "France,Nigeria", + `worker should have returned the expected result in ${context}` + ); + + URL.revokeObjectURL(WORKER_URL); + + const fn = workerify(x => x * x); + is(await fn(5), 25, `workerify works in ${context}`); + fn.destroy(); + + worker.destroy(); +} + +async function testTransfer() { + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + const { workerify } = ChromeUtils.import( + "resource://devtools/shared/worker/worker.js" + ); + const workerFn = workerify(({ buf }) => buf.byteLength); + const buf = new ArrayBuffer(BUFFER_SIZE); + + is( + buf.byteLength, + BUFFER_SIZE, + "Size of the buffer before transfer is correct." + ); + + is(await workerFn({ buf }), 8, "Sent array buffer to worker"); + is(buf.byteLength, 8, "Array buffer was copied, not transferred."); + + is(await workerFn({ buf }, [buf]), 8, "Sent array buffer to worker"); + is(buf.byteLength, 0, "Array buffer was transferred, not copied."); + + workerFn.destroy(); +} diff --git a/devtools/shared/worker/tests/browser/browser_worker-02.js b/devtools/shared/worker/tests/browser/browser_worker-02.js new file mode 100644 index 0000000000..80c50cf887 --- /dev/null +++ b/devtools/shared/worker/tests/browser/browser_worker-02.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests errors are handled properly by the DevToolsWorker. + +const { + DevToolsWorker, +} = require("resource://devtools/shared/worker/worker.js"); + +const blob = new Blob( + [ + ` +importScripts("resource://gre/modules/workers/require.js"); +const { createTask } = require("resource://devtools/shared/worker/helper.js"); + +createTask(self, "myTask", function({ + shouldThrow, +} = {}) { + if (shouldThrow) { + throw new Error("err"); + } + + return "OK"; +}); + `, + ], + { type: "application/javascript" } +); + +add_task(async function () { + try { + new DevToolsWorker("resource://i/dont/exist.js"); + ok(false, "Creating a DevToolsWorker with an invalid URL throws"); + } catch (e) { + ok(true, "Creating a DevToolsWorker with an invalid URL throws"); + } + + const WORKER_URL = URL.createObjectURL(blob); + const worker = new DevToolsWorker(WORKER_URL); + try { + await worker.performTask("myTask", { shouldThrow: true }); + ok( + false, + "DevToolsWorker returns a rejected promise when an error occurs in the worker" + ); + } catch (e) { + ok( + true, + "DevToolsWorker returns a rejected promise when an error occurs in the worker" + ); + } + + try { + await worker.performTask("not a real task"); + ok( + false, + "DevToolsWorker returns a rejected promise when task does not exist" + ); + } catch (e) { + ok( + true, + "DevToolsWorker returns a rejected promise when task does not exist" + ); + } + + worker.destroy(); + try { + await worker.performTask("myTask"); + ok( + false, + "DevToolsWorker rejects when performing a task on a destroyed worker" + ); + } catch (e) { + ok( + true, + "DevToolsWorker rejects when performing a task on a destroyed worker" + ); + } + + URL.revokeObjectURL(WORKER_URL); +}); diff --git a/devtools/shared/worker/tests/browser/browser_worker-03.js b/devtools/shared/worker/tests/browser/browser_worker-03.js new file mode 100644 index 0000000000..185ba92d5e --- /dev/null +++ b/devtools/shared/worker/tests/browser/browser_worker-03.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the devtools/shared/worker can handle: +// returned primitives (or promise or Error) +// +// And tests `workerify` by doing so. + +const { workerify } = require("resource://devtools/shared/worker/worker.js"); +function square(x) { + return x * x; +} + +function squarePromise(x) { + return new Promise(resolve => resolve(x * x)); +} + +function squareError(x) { + return new Error("Nope"); +} + +function squarePromiseReject(x) { + return new Promise((_, reject) => reject("Nope")); +} + +registerCleanupFunction(function () { + Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads"); +}); + +add_task(async function () { + // Needed for blob:null + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + let fn = workerify(square); + is(await fn(5), 25, "return primitives successful"); + fn.destroy(); + + fn = workerify(squarePromise); + is(await fn(5), 25, "promise primitives successful"); + fn.destroy(); + + fn = workerify(squareError); + try { + await fn(5); + ok(false, "return error should reject"); + } catch (e) { + ok(true, "return error should reject"); + } + fn.destroy(); + + fn = workerify(squarePromiseReject); + try { + await fn(5); + ok(false, "returned rejected promise rejects"); + } catch (e) { + ok(true, "returned rejected promise rejects"); + } + fn.destroy(); +}); diff --git a/devtools/shared/worker/worker.js b/devtools/shared/worker/worker.js new file mode 100644 index 0000000000..4d753a928e --- /dev/null +++ b/devtools/shared/worker/worker.js @@ -0,0 +1,198 @@ +/* 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"; + +/* global ChromeWorker */ + +(function (factory) { + if (this.module && module.id.includes("worker")) { + // require + const dumpn = require("devtools/shared/DevToolsUtils").dumpn; + factory.call( + this, + require, + exports, + module, + { Cc, Ci, Cu }, + ChromeWorker, + dumpn + ); + } else { + // Cu.import + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + this.isWorker = false; + this.console = console; + factory.call( + this, + require, + this, + { exports: this }, + { Cc, Ci, Cu }, + ChromeWorker, + null + ); + this.EXPORTED_SYMBOLS = ["DevToolsWorker", "workerify"]; + } +}).call( + this, + function (require, exports, module, { Ci, Cc }, ChromeWorker, dumpn) { + let MESSAGE_COUNTER = 0; + + /** + * Creates a wrapper around a ChromeWorker, providing easy + * communication to offload demanding tasks. The corresponding URL + * must implement the interface provided by `devtools/shared/worker/helper`. + * + * @param {string} url + * The URL of the worker. + * @param Object opts + * An option with the following optional fields: + * - name: a name that will be printed with logs + * - verbose: log incoming and outgoing messages + */ + function DevToolsWorker(url, opts) { + opts = opts || {}; + this._worker = new ChromeWorker(url); + this._verbose = opts.verbose; + this._name = opts.name; + + this._worker.addEventListener("error", this.onError); + } + exports.DevToolsWorker = DevToolsWorker; + + /** + * Performs the given task in a chrome worker, passing in data. + * Returns a promise that resolves when the task is completed, resulting in + * the return value of the task. + * + * @param {string} task + * The name of the task to execute in the worker. + * @param {any} data + * Data to be passed into the task implemented by the worker. + * @param {undefined|Array} transfer + * Optional array of transferable objects to transfer ownership of. + * @return {Promise} + */ + DevToolsWorker.prototype.performTask = function (task, data, transfer) { + if (this._destroyed) { + return Promise.reject( + "Cannot call performTask on a destroyed DevToolsWorker" + ); + } + const worker = this._worker; + const id = ++MESSAGE_COUNTER; + const payload = { task, id, data }; + + if (this._verbose && dumpn) { + dumpn( + "Sending message to worker" + + (this._name ? " (" + this._name + ")" : "") + + ": " + + JSON.stringify(payload, null, 2) + ); + } + worker.postMessage(payload, transfer); + + return new Promise((resolve, reject) => { + const listener = ({ data: result }) => { + if (this._verbose && dumpn) { + dumpn( + "Received message from worker" + + (this._name ? " (" + this._name + ")" : "") + + ": " + + JSON.stringify(result, null, 2) + ); + } + + if (result.id !== id) { + return; + } + worker.removeEventListener("message", listener); + if (result.error) { + reject(result.error); + } else { + resolve(result.response); + } + }; + + worker.addEventListener("message", listener); + }); + }; + + /** + * Terminates the underlying worker. Use when no longer needing the worker. + */ + DevToolsWorker.prototype.destroy = function () { + this._worker.terminate(); + this._worker = null; + this._destroyed = true; + }; + + DevToolsWorker.prototype.onError = function ({ + message, + filename, + lineno, + }) { + dump(new Error(message + " @ " + filename + ":" + lineno) + "\n"); + }; + + /** + * Takes a function and returns a Worker-wrapped version of the same function. + * Returns a promise upon resolution. + * @see `./devtools/shared/shared/tests/browser/browser_devtools-worker-03.js + * + * ⚠ This should only be used for tests or A/B testing performance ⚠ + * + * The original function must: + * + * Be a pure function, that is, not use any variables not declared within the + * function, or its arguments. + * + * Return a value or a promise. + * + * Note any state change in the worker will not affect the callee's context. + * + * @param {function} fn + * @return {function} + */ + function workerify(fn) { + console.warn( + "`workerify` should only be used in tests or measuring performance. " + + "This creates an object URL on the browser window, and should not be " + + "used in production." + ); + // Fetch modules here as we don't want to include it normally. + const { URL, Blob } = + Services.wm.getMostRecentWindow("navigator:browser"); + const stringifiedFn = createWorkerString(fn); + const blob = new Blob([stringifiedFn]); + const url = URL.createObjectURL(blob); + const worker = new DevToolsWorker(url); + + const wrapperFn = (data, transfer) => + worker.performTask("workerifiedTask", data, transfer); + + wrapperFn.destroy = function () { + URL.revokeObjectURL(url); + worker.destroy(); + }; + + return wrapperFn; + } + exports.workerify = workerify; + + /** + * Takes a function, and stringifies it, attaching the worker-helper.js + * boilerplate hooks. + */ + function createWorkerString(fn) { + return `importScripts("resource://gre/modules/workers/require.js"); + const { createTask } = require("resource://devtools/shared/worker/helper.js"); + createTask(self, "workerifiedTask", ${fn.toString()});`; + } + } +); -- cgit v1.2.3